- Relax constraints: live_stash ~> 0.2 → ~> 1.0, nx ~> 0.9 → ~> 0.13, sutra_ui ~> 0.3.0 → ~> 0.4.0 - Within-constraint bumps: exla 0.12→0.13, live_table 0.4.1→0.4.2, phoenix_live_view 1.2.6→1.2.7, postgrex 0.22.2→0.22.3, req 0.6.2→0.6.3, stream_data 1.3→1.4, plus transitive (castore, circular_buffer, ecto, mint, plug) - Breaking: live_table 0.4.2 removed get_merged_table_options/0 — replaced with LiveTable.TableConfig.get_table_options(table_options()) in RoverLocationsLive and RoverPlanningLive - igniter stays at ~> 0.7 (pinned by live_table ~> 0.7.0)
459 lines
14 KiB
Elixir
459 lines
14 KiB
Elixir
defmodule MicrowavepropWeb.RoverLocationsLive do
|
|
@moduledoc """
|
|
Globally-shared list of rover-friendly (or bad) parking
|
|
locations. Everyone sees the table; only logged-in users can add or
|
|
edit entries (and only the creator can edit/delete their own).
|
|
"""
|
|
use MicrowavepropWeb, :live_view
|
|
use MicrowavepropWeb.LiveTableResource, schema: Microwaveprop.Rover.Location
|
|
|
|
import Ecto.Query
|
|
|
|
alias Microwaveprop.Accounts.User
|
|
alias Microwaveprop.Radio.Maidenhead
|
|
alias Microwaveprop.Repo
|
|
alias Microwaveprop.Rover
|
|
alias Microwaveprop.Rover.Location
|
|
|
|
@valid_status_filters ~w(good bad)
|
|
|
|
@spec table_options() :: map()
|
|
def table_options do
|
|
%{sorting: %{default_sort: [inserted_at: :desc]}}
|
|
end
|
|
|
|
@spec fields() :: keyword()
|
|
def fields do
|
|
[
|
|
id: %{label: "ID", hidden: true},
|
|
user_id: %{label: "Owner", hidden: true},
|
|
status: %{label: "Status", sortable: true, renderer: &status_cell/1},
|
|
lat: %{label: "Location", sortable: true, renderer: &location_cell/2},
|
|
lon: %{label: "Lon", hidden: true},
|
|
notes: %{label: "Notes", searchable: true, renderer: ¬es_cell/1},
|
|
inserted_at: %{label: "Added (UTC)", sortable: true, renderer: &format_ts/1}
|
|
]
|
|
end
|
|
|
|
@spec filters() :: list()
|
|
def filters, do: []
|
|
|
|
@doc false
|
|
# Called by LiveTable to get the base queryable. Filters by status when
|
|
# the user picks one in the in-page select; nil means show all.
|
|
@spec visible_query_provider(status_filter :: term()) :: Ecto.Query.t()
|
|
def visible_query_provider(status_filter) do
|
|
base = from(l in Location, as: :resource)
|
|
|
|
case status_filter do
|
|
"good" -> from(l in base, where: l.status == :good)
|
|
"bad" -> from(l in base, where: l.status == :bad)
|
|
_ -> base
|
|
end
|
|
end
|
|
|
|
@impl true
|
|
def mount(_params, _session, socket) do
|
|
{:ok,
|
|
socket
|
|
|> assign(
|
|
page_title: "Rover Locations",
|
|
form: nil,
|
|
editing_id: nil,
|
|
grid_input: "",
|
|
status_filter: nil
|
|
)
|
|
|> assign(:data_provider, {__MODULE__, :visible_query_provider, [nil]})}
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("filter_status", %{"value" => value}, socket) do
|
|
status = parse_status(value)
|
|
data_provider = {__MODULE__, :visible_query_provider, [status]}
|
|
|
|
{:noreply,
|
|
socket
|
|
|> assign(:status_filter, status)
|
|
|> assign(:data_provider, data_provider)
|
|
|> reload()}
|
|
end
|
|
|
|
def handle_event("new", _params, socket) do
|
|
if user = current_user(socket) do
|
|
{:noreply, assign(socket, form: blank_form(user), editing_id: nil, grid_input: "")}
|
|
else
|
|
{:noreply, put_flash(socket, :error, "Sign in to add a location.")}
|
|
end
|
|
end
|
|
|
|
def handle_event("edit", %{"id" => id}, socket) do
|
|
user = current_user(socket)
|
|
|
|
cond do
|
|
is_nil(user) ->
|
|
{:noreply, put_flash(socket, :error, "Sign in to edit a location.")}
|
|
|
|
loc = editable_location(user, id) ->
|
|
cs = Location.changeset(loc, %{})
|
|
grid = Maidenhead.from_latlon(loc.lat, loc.lon, 6)
|
|
{:noreply, assign(socket, form: to_form(cs), editing_id: id, grid_input: grid)}
|
|
|
|
true ->
|
|
{:noreply, put_flash(socket, :error, "You can only edit your own locations.")}
|
|
end
|
|
end
|
|
|
|
def handle_event("cancel", _, socket) do
|
|
{:noreply, assign(socket, form: nil, editing_id: nil, grid_input: "")}
|
|
end
|
|
|
|
def handle_event("validate", %{"location" => params} = event_params, socket) do
|
|
target = event_params["_target"]
|
|
{params, grid_input} = sync_grid_and_latlon(params, target, socket.assigns.grid_input)
|
|
|
|
base = if socket.assigns.editing_id, do: location_for_edit(socket), else: %Location{}
|
|
cs = base |> Location.changeset(params) |> Map.put(:action, :validate)
|
|
{:noreply, assign(socket, form: to_form(cs), grid_input: grid_input)}
|
|
end
|
|
|
|
def handle_event("save", %{"location" => params}, socket) do
|
|
user = current_user(socket)
|
|
|
|
cond do
|
|
is_nil(user) ->
|
|
{:noreply, put_flash(socket, :error, "Sign in required.")}
|
|
|
|
socket.assigns.editing_id ->
|
|
save_update(socket, user, params)
|
|
|
|
true ->
|
|
save_create(socket, user, params)
|
|
end
|
|
end
|
|
|
|
def handle_event("delete", %{"id" => id}, socket) do
|
|
case current_user(socket) do
|
|
%User{} = user ->
|
|
case Rover.delete_location(user, id) do
|
|
{:ok, _} ->
|
|
{:noreply,
|
|
socket
|
|
|> put_flash(:info, "Location removed.")
|
|
|> reload()}
|
|
|
|
{:error, :not_found} ->
|
|
{:noreply, put_flash(socket, :error, "You can only delete your own locations.")}
|
|
end
|
|
|
|
_ ->
|
|
{:noreply, put_flash(socket, :error, "Sign in required.")}
|
|
end
|
|
end
|
|
|
|
defp save_create(socket, user, params) do
|
|
case Rover.create_location(user, params) do
|
|
{:ok, _loc} ->
|
|
{:noreply,
|
|
socket
|
|
|> assign(form: nil, editing_id: nil)
|
|
|> put_flash(:info, "Location added.")
|
|
|> reload()}
|
|
|
|
{:error, cs} ->
|
|
{:noreply, assign(socket, form: to_form(cs))}
|
|
end
|
|
end
|
|
|
|
defp save_update(socket, user, params) do
|
|
case Rover.update_location(user, socket.assigns.editing_id, params) do
|
|
{:ok, _loc} ->
|
|
{:noreply,
|
|
socket
|
|
|> assign(form: nil, editing_id: nil)
|
|
|> put_flash(:info, "Location updated.")
|
|
|> reload()}
|
|
|
|
{:error, :not_found} ->
|
|
{:noreply, put_flash(socket, :error, "You can only edit your own locations.")}
|
|
|
|
{:error, cs} ->
|
|
{:noreply, assign(socket, form: to_form(cs))}
|
|
end
|
|
end
|
|
|
|
defp location_for_edit(%{assigns: %{editing_id: id}}) when is_binary(id) do
|
|
Repo.get(Location, id) || %Location{}
|
|
end
|
|
|
|
defp location_for_edit(_), do: %Location{}
|
|
|
|
defp editable_location(%User{is_admin: true}, id) do
|
|
Repo.get(Location, id)
|
|
end
|
|
|
|
defp editable_location(%User{id: user_id}, id) do
|
|
case Repo.get(Location, id) do
|
|
%Location{user_id: ^user_id} = loc -> loc
|
|
_ -> nil
|
|
end
|
|
end
|
|
|
|
defp blank_form(_user) do
|
|
%Location{} |> Location.changeset(%{}) |> to_form()
|
|
end
|
|
|
|
# Re-runs the LiveTable fetch with the current data_provider + options.
|
|
# Cheaper and simpler than push_patch — preserves search/sort/page state
|
|
# without round-tripping through the URL.
|
|
defp reload(socket) do
|
|
options = socket.assigns.options
|
|
data_provider = socket.assigns.data_provider
|
|
table_options = LiveTable.TableConfig.get_table_options(table_options())
|
|
|
|
{resources, updated_options} = fetch_resources(options, data_provider)
|
|
|
|
socket
|
|
|> assign_to_socket(resources, table_options)
|
|
|> assign(:options, updated_options)
|
|
end
|
|
|
|
defp parse_status(value) when value in @valid_status_filters, do: value
|
|
defp parse_status(_), do: nil
|
|
|
|
# Two-way grid <-> lat/lon sync. `_target` from the phx-change event
|
|
# tells us which field the user actually edited; we mirror the change
|
|
# into the other side. Unparseable input leaves the other side alone.
|
|
defp sync_grid_and_latlon(params, ["location", "grid"], _old_grid) do
|
|
raw = params |> Map.get("grid", "") |> to_string() |> String.trim()
|
|
|
|
case Maidenhead.to_latlon(raw) do
|
|
{:ok, {lat, lon}} ->
|
|
params =
|
|
params
|
|
|> Map.put("lat", format_coord(lat))
|
|
|> Map.put("lon", format_coord(lon))
|
|
|
|
{params, raw}
|
|
|
|
:error ->
|
|
{params, raw}
|
|
end
|
|
end
|
|
|
|
defp sync_grid_and_latlon(params, ["location", field], old_grid) when field in ["lat", "lon"] do
|
|
with {lat, ""} <- params |> Map.get("lat", "") |> to_string() |> String.trim() |> Float.parse(),
|
|
{lon, ""} <- params |> Map.get("lon", "") |> to_string() |> String.trim() |> Float.parse(),
|
|
lat when lat >= -90.0 and lat <= 90.0 <- lat,
|
|
lon when lon >= -180.0 and lon <= 180.0 <- lon do
|
|
grid = Maidenhead.from_latlon(lat, lon, 6)
|
|
{Map.put(params, "grid", grid), grid}
|
|
else
|
|
_ -> {params, old_grid}
|
|
end
|
|
end
|
|
|
|
defp sync_grid_and_latlon(params, _target, old_grid), do: {params, old_grid}
|
|
|
|
defp format_coord(value) do
|
|
value |> Float.round(6) |> Float.to_string()
|
|
end
|
|
|
|
defp current_user(%Phoenix.LiveView.Socket{assigns: assigns}), do: scope_user(assigns)
|
|
defp current_user(assigns) when is_map(assigns), do: scope_user(assigns)
|
|
|
|
defp scope_user(assigns) do
|
|
case assigns[:current_scope] do
|
|
%{user: %User{} = user} -> user
|
|
_ -> nil
|
|
end
|
|
end
|
|
|
|
defp can_modify?(scope, %{user_id: uid}) do
|
|
case scope_user(%{current_scope: scope}) do
|
|
%User{is_admin: true} -> true
|
|
%User{id: ^uid} when not is_nil(uid) -> true
|
|
_ -> false
|
|
end
|
|
end
|
|
|
|
defp can_modify?(_, _), do: false
|
|
|
|
defp actions_for(scope) do
|
|
[
|
|
buttons: fn %{record: record} ->
|
|
row_actions(record, scope)
|
|
end
|
|
]
|
|
end
|
|
|
|
defp row_actions(record, scope) do
|
|
assigns = %{record: record, can_modify?: can_modify?(scope, record)}
|
|
|
|
~H"""
|
|
<div :if={@can_modify?} class="flex gap-2">
|
|
<button
|
|
type="button"
|
|
phx-click="edit"
|
|
phx-value-id={@record.id}
|
|
class="btn btn-xs btn-ghost"
|
|
>
|
|
Edit
|
|
</button>
|
|
<button
|
|
type="button"
|
|
phx-click="delete"
|
|
phx-value-id={@record.id}
|
|
data-confirm="Delete this location?"
|
|
class="btn btn-xs btn-ghost text-error"
|
|
>
|
|
Delete
|
|
</button>
|
|
</div>
|
|
"""
|
|
end
|
|
|
|
defp status_cell(:good) do
|
|
assigns = %{}
|
|
~H|<span class="badge badge-success">Good</span>|
|
|
end
|
|
|
|
defp status_cell(:bad) do
|
|
assigns = %{}
|
|
~H|<span class="badge badge-error">Bad</span>|
|
|
end
|
|
|
|
defp status_cell(_), do: ""
|
|
|
|
defp location_cell(_value, %{id: id, lat: lat, lon: lon}) when is_number(lat) and is_number(lon) do
|
|
grid = Maidenhead.from_latlon(lat, lon, 10)
|
|
assigns = %{id: id, lat: lat, lon: lon, grid: grid}
|
|
|
|
~H"""
|
|
<.link navigate={~p"/rover-locations/#{@id}"} class="flex flex-col hover:underline">
|
|
<span class="font-mono text-sm font-semibold">{@grid}</span>
|
|
<span class="font-mono text-xs text-base-content/60">
|
|
{Float.round(@lat, 5)}, {Float.round(@lon, 5)}
|
|
</span>
|
|
</.link>
|
|
"""
|
|
end
|
|
|
|
defp location_cell(_, _), do: ""
|
|
|
|
defp notes_cell(nil), do: ""
|
|
defp notes_cell(""), do: ""
|
|
|
|
defp notes_cell(notes) when is_binary(notes) do
|
|
assigns = %{notes: notes}
|
|
~H|<span class="whitespace-pre-line">{@notes}</span>|
|
|
end
|
|
|
|
defp format_ts(nil), do: ""
|
|
defp format_ts(%DateTime{} = dt), do: Calendar.strftime(dt, "%Y-%m-%d")
|
|
defp format_ts(%NaiveDateTime{} = dt), do: Calendar.strftime(dt, "%Y-%m-%d")
|
|
defp format_ts(other), do: to_string(other)
|
|
|
|
@impl true
|
|
def render(assigns) do
|
|
~H"""
|
|
<Layouts.app flash={@flash} current_scope={@current_scope} max_width="max-w-5xl">
|
|
<.header>
|
|
Rover Locations
|
|
<:subtitle>Community-shared parking spots for portable rover ops.</:subtitle>
|
|
<:actions>
|
|
<.link navigate={~p"/rover-locations/map"} class="btn btn-ghost">
|
|
<.icon name="hero-map" class="w-5 h-5" /> Map
|
|
</.link>
|
|
<button
|
|
type="button"
|
|
phx-click="new"
|
|
class="btn btn-primary"
|
|
disabled={is_nil(current_user(assigns))}
|
|
>
|
|
<.icon name="hero-plus" class="w-5 h-5" /> Add location
|
|
</button>
|
|
</:actions>
|
|
</.header>
|
|
|
|
<p :if={is_nil(current_user(assigns))} class="alert alert-info text-sm mb-4">
|
|
<a href={~p"/users/log-in"} class="link link-primary">Sign in</a> to add or edit locations.
|
|
</p>
|
|
|
|
<div :if={@form} class="card bg-base-200 p-4 mb-4">
|
|
<h2 class="text-lg font-semibold mb-2">
|
|
{if @editing_id, do: "Edit location", else: "New location"}
|
|
</h2>
|
|
<.form for={@form} phx-change="validate" phx-submit="save" id="location-form">
|
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
|
<.input
|
|
name="location[grid]"
|
|
value={@grid_input}
|
|
type="text"
|
|
label="Grid (Maidenhead)"
|
|
placeholder="EM12kp"
|
|
/>
|
|
<.input
|
|
field={@form[:status]}
|
|
type="select"
|
|
label="Status"
|
|
options={[{"Good", :good}, {"Bad", :bad}]}
|
|
/>
|
|
</div>
|
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
|
<.input
|
|
field={@form[:lat]}
|
|
type="number"
|
|
step="any"
|
|
label="Latitude"
|
|
placeholder="32.5"
|
|
/>
|
|
<.input
|
|
field={@form[:lon]}
|
|
type="number"
|
|
step="any"
|
|
label="Longitude"
|
|
placeholder="-97.5"
|
|
/>
|
|
</div>
|
|
<.input
|
|
field={@form[:notes]}
|
|
type="textarea"
|
|
label="Notes"
|
|
rows="3"
|
|
placeholder="Trail access, line-of-sight notes, contact info, etc."
|
|
/>
|
|
<div class="flex gap-2 mt-3">
|
|
<button type="submit" class="btn btn-primary btn-sm">
|
|
{if @editing_id, do: "Save", else: "Add"}
|
|
</button>
|
|
<button type="button" phx-click="cancel" class="btn btn-ghost btn-sm">
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
</.form>
|
|
</div>
|
|
|
|
<form phx-change="filter_status" class="mb-4 flex items-center gap-2">
|
|
<label for="status-filter" class="text-sm font-medium">Show:</label>
|
|
<select
|
|
id="status-filter"
|
|
name="value"
|
|
class="select select-sm select-bordered"
|
|
>
|
|
<option value="all" selected={is_nil(@status_filter)}>All</option>
|
|
<option value="good" selected={@status_filter == "good"}>Good</option>
|
|
<option value="bad" selected={@status_filter == "bad"}>Bad</option>
|
|
</select>
|
|
</form>
|
|
|
|
<.live_table
|
|
fields={fields()}
|
|
filters={filters()}
|
|
options={@options}
|
|
streams={@streams}
|
|
actions={actions_for(@current_scope)}
|
|
/>
|
|
</Layouts.app>
|
|
"""
|
|
end
|
|
end
|