Beacon improvements: anon submit, notes, /map nav, admin backfill
- Anonymous users can submit beacons (held pending admin approval) - Added notes field to beacons (textarea on form, shown on detail page) - Added Beacons link to /map sidebar nav (mobile + desktop), removed stale Rover Planner link - Moved /backfill to /admin/backfill under the admin live_session - Beacon detail map zooms in two extra levels after fitBounds
This commit is contained in:
parent
4323f81b97
commit
ba0f1161a7
11 changed files with 93 additions and 32 deletions
|
|
@ -56,9 +56,11 @@ export const BeaconMap = {
|
||||||
offset: [0, -10]
|
offset: [0, -10]
|
||||||
})
|
})
|
||||||
|
|
||||||
// Fit to the rendered cells (initial view already set above).
|
// Fit to the rendered cells (initial view already set above), then zoom
|
||||||
|
// in two steps so the beacon's immediate surroundings are visible.
|
||||||
if (allBounds.length > 0) {
|
if (allBounds.length > 0) {
|
||||||
map.fitBounds(L.latLngBounds(allBounds), {padding: [20, 20]})
|
map.fitBounds(L.latLngBounds(allBounds), {padding: [20, 20]})
|
||||||
|
map.setZoom(map.getZoom() + 2)
|
||||||
}
|
}
|
||||||
|
|
||||||
setTimeout(() => map.invalidateSize(), 50)
|
setTimeout(() => map.invalidateSize(), 50)
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
defmodule Microwaveprop.Beacons do
|
defmodule Microwaveprop.Beacons do
|
||||||
@moduledoc """
|
@moduledoc """
|
||||||
The Beacons context. Any authenticated user can submit a beacon,
|
The Beacons context. Anyone can submit a beacon — authenticated or not —
|
||||||
but submissions are held as unapproved until an admin approves
|
but submissions are held as unapproved until an admin approves them.
|
||||||
them. Only approved beacons appear in the public list.
|
Only approved beacons appear in the public list.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import Ecto.Query, warn: false
|
import Ecto.Query, warn: false
|
||||||
|
|
@ -50,8 +50,11 @@ defmodule Microwaveprop.Beacons do
|
||||||
def get_beacon!(id), do: Repo.get!(Beacon, id)
|
def get_beacon!(id), do: Repo.get!(Beacon, id)
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Creates a beacon. The user is recorded on the row as the creator.
|
Creates a beacon. When a user is provided they are recorded as the
|
||||||
|
creator; anonymous submissions pass `nil` and leave `user_id` unset.
|
||||||
"""
|
"""
|
||||||
|
def create_beacon(user, attrs)
|
||||||
|
|
||||||
def create_beacon(%User{} = user, attrs) do
|
def create_beacon(%User{} = user, attrs) do
|
||||||
%Beacon{user_id: user.id}
|
%Beacon{user_id: user.id}
|
||||||
|> Beacon.changeset(attrs)
|
|> Beacon.changeset(attrs)
|
||||||
|
|
@ -59,6 +62,13 @@ defmodule Microwaveprop.Beacons do
|
||||||
|> broadcast_if_ok(:created)
|
|> broadcast_if_ok(:created)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def create_beacon(nil, attrs) do
|
||||||
|
%Beacon{}
|
||||||
|
|> Beacon.changeset(attrs)
|
||||||
|
|> Repo.insert()
|
||||||
|
|> broadcast_if_ok(:created)
|
||||||
|
end
|
||||||
|
|
||||||
@doc "Updates a beacon."
|
@doc "Updates a beacon."
|
||||||
def update_beacon(%Beacon{} = beacon, attrs) do
|
def update_beacon(%Beacon{} = beacon, attrs) do
|
||||||
beacon
|
beacon
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ defmodule Microwaveprop.Beacons.Beacon do
|
||||||
field :on_the_air, :boolean, default: true
|
field :on_the_air, :boolean, default: true
|
||||||
field :approved, :boolean, default: false
|
field :approved, :boolean, default: false
|
||||||
field :keying, :string, default: "on_off"
|
field :keying, :string, default: "on_off"
|
||||||
|
field :notes, :string
|
||||||
field :user_id, :binary_id
|
field :user_id, :binary_id
|
||||||
|
|
||||||
timestamps(type: :utc_datetime)
|
timestamps(type: :utc_datetime)
|
||||||
|
|
@ -46,7 +47,8 @@ defmodule Microwaveprop.Beacons.Beacon do
|
||||||
:power_mw,
|
:power_mw,
|
||||||
:height_ft,
|
:height_ft,
|
||||||
:on_the_air,
|
:on_the_air,
|
||||||
:keying
|
:keying,
|
||||||
|
:notes
|
||||||
])
|
])
|
||||||
|> update_change(:callsign, fn cs -> cs && String.upcase(String.trim(cs)) end)
|
|> update_change(:callsign, fn cs -> cs && String.upcase(String.trim(cs)) end)
|
||||||
|> maybe_fill_latlon()
|
|> maybe_fill_latlon()
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,7 @@ defmodule MicrowavepropWeb.BeaconLive.Form do
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
<.input field={@form[:on_the_air]} type="checkbox" label="On the air" />
|
<.input field={@form[:on_the_air]} type="checkbox" label="On the air" />
|
||||||
|
<.input field={@form[:notes]} type="textarea" label="Notes" />
|
||||||
<footer>
|
<footer>
|
||||||
<.button phx-disable-with="Saving..." variant="primary">Save Beacon</.button>
|
<.button phx-disable-with="Saving..." variant="primary">Save Beacon</.button>
|
||||||
<.button navigate={return_path(@return_to, @beacon)}>Cancel</.button>
|
<.button navigate={return_path(@return_to, @beacon)}>Cancel</.button>
|
||||||
|
|
@ -113,13 +114,18 @@ defmodule MicrowavepropWeb.BeaconLive.Form do
|
||||||
end
|
end
|
||||||
|
|
||||||
defp save_beacon(socket, :new, beacon_params) do
|
defp save_beacon(socket, :new, beacon_params) do
|
||||||
user = socket.assigns.current_scope.user
|
user = current_user(socket.assigns.current_scope)
|
||||||
|
|
||||||
case Beacons.create_beacon(user, beacon_params) do
|
case Beacons.create_beacon(user, beacon_params) do
|
||||||
{:ok, beacon} ->
|
{:ok, beacon} ->
|
||||||
|
flash =
|
||||||
|
if user,
|
||||||
|
do: "Beacon submitted — pending admin approval.",
|
||||||
|
else: "Thanks! Your beacon has been submitted and is awaiting admin approval."
|
||||||
|
|
||||||
{:noreply,
|
{:noreply,
|
||||||
socket
|
socket
|
||||||
|> put_flash(:info, "Beacon created")
|
|> put_flash(:info, flash)
|
||||||
|> push_navigate(to: return_path(socket.assigns.return_to, beacon))}
|
|> push_navigate(to: return_path(socket.assigns.return_to, beacon))}
|
||||||
|
|
||||||
{:error, %Ecto.Changeset{} = changeset} ->
|
{:error, %Ecto.Changeset{} = changeset} ->
|
||||||
|
|
@ -127,6 +133,9 @@ defmodule MicrowavepropWeb.BeaconLive.Form do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp current_user(%{user: user}), do: user
|
||||||
|
defp current_user(_), do: nil
|
||||||
|
|
||||||
defp return_path("index", _beacon), do: ~p"/beacons"
|
defp return_path("index", _beacon), do: ~p"/beacons"
|
||||||
defp return_path("show", beacon), do: ~p"/beacons/#{beacon}"
|
defp return_path("show", beacon), do: ~p"/beacons/#{beacon}"
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -12,11 +12,7 @@ defmodule MicrowavepropWeb.BeaconLive.Index do
|
||||||
Beacons
|
Beacons
|
||||||
<:subtitle>Microwave beacons tracked by NTMS.</:subtitle>
|
<:subtitle>Microwave beacons tracked by NTMS.</:subtitle>
|
||||||
<:actions>
|
<:actions>
|
||||||
<.button
|
<.button variant="primary" navigate={~p"/beacons/new"}>
|
||||||
:if={authenticated?(@current_scope)}
|
|
||||||
variant="primary"
|
|
||||||
navigate={~p"/beacons/new"}
|
|
||||||
>
|
|
||||||
<.icon name="hero-plus" /> Submit Beacon
|
<.icon name="hero-plus" /> Submit Beacon
|
||||||
</.button>
|
</.button>
|
||||||
</:actions>
|
</:actions>
|
||||||
|
|
@ -166,9 +162,6 @@ defmodule MicrowavepropWeb.BeaconLive.Index do
|
||||||
defp admin?(%{user: %{is_admin: true}}), do: true
|
defp admin?(%{user: %{is_admin: true}}), do: true
|
||||||
defp admin?(_), do: false
|
defp admin?(_), do: false
|
||||||
|
|
||||||
defp authenticated?(%{user: %{}}), do: true
|
|
||||||
defp authenticated?(_), do: false
|
|
||||||
|
|
||||||
defp keying_label("on_off"), do: "On/Off"
|
defp keying_label("on_off"), do: "On/Off"
|
||||||
defp keying_label("fsk"), do: "FSK"
|
defp keying_label("fsk"), do: "FSK"
|
||||||
defp keying_label(other), do: other
|
defp keying_label(other), do: other
|
||||||
|
|
|
||||||
|
|
@ -96,6 +96,9 @@ defmodule MicrowavepropWeb.BeaconLive.Show do
|
||||||
<:item title="Height above ground (ft)">{@beacon.height_ft}</:item>
|
<:item title="Height above ground (ft)">{@beacon.height_ft}</:item>
|
||||||
<:item title="Keying">{keying_label(@beacon.keying)}</:item>
|
<:item title="Keying">{keying_label(@beacon.keying)}</:item>
|
||||||
<:item title="On the air">{if @beacon.on_the_air, do: "Yes", else: "No"}</:item>
|
<:item title="On the air">{if @beacon.on_the_air, do: "Yes", else: "No"}</:item>
|
||||||
|
<:item :if={@beacon.notes && @beacon.notes != ""} title="Notes">
|
||||||
|
<div class="whitespace-pre-wrap">{@beacon.notes}</div>
|
||||||
|
</:item>
|
||||||
</.list>
|
</.list>
|
||||||
</Layouts.app>
|
</Layouts.app>
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -359,8 +359,8 @@ defmodule MicrowavepropWeb.MapLive do
|
||||||
<.link navigate="/path" class="btn btn-xs btn-ghost justify-start">
|
<.link navigate="/path" class="btn btn-xs btn-ghost justify-start">
|
||||||
Path Calculator
|
Path Calculator
|
||||||
</.link>
|
</.link>
|
||||||
<.link navigate="/rover" class="btn btn-xs btn-ghost justify-start">
|
<.link navigate="/beacons" class="btn btn-xs btn-ghost justify-start">
|
||||||
Rover Planner
|
Beacons
|
||||||
</.link>
|
</.link>
|
||||||
<.link navigate="/weather" class="btn btn-xs btn-ghost justify-start">
|
<.link navigate="/weather" class="btn btn-xs btn-ghost justify-start">
|
||||||
Weather Map
|
Weather Map
|
||||||
|
|
@ -500,7 +500,7 @@ defmodule MicrowavepropWeb.MapLive do
|
||||||
<.link navigate="/path">Path Calculator</.link>
|
<.link navigate="/path">Path Calculator</.link>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<.link navigate="/rover">Rover Planner</.link>
|
<.link navigate="/beacons">Beacons</.link>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<.link navigate="/weather">Weather Map</.link>
|
<.link navigate="/weather">Weather Map</.link>
|
||||||
|
|
|
||||||
|
|
@ -27,19 +27,16 @@ defmodule MicrowavepropWeb.Router do
|
||||||
# Health check — no pipeline, minimal overhead
|
# Health check — no pipeline, minimal overhead
|
||||||
get "/health", MicrowavepropWeb.HealthController, :check, log: false
|
get "/health", MicrowavepropWeb.HealthController, :check, log: false
|
||||||
|
|
||||||
# Authenticated (non-admin) and admin-only routes must come first so that
|
# Admin-only routes must come first so that literal segments like
|
||||||
# literal segments like `/beacons/new` are registered before the public
|
# `/beacons/:id/edit` are registered before the public `/beacons/:id`.
|
||||||
# `/beacons/:id`.
|
|
||||||
scope "/", MicrowavepropWeb do
|
scope "/", MicrowavepropWeb do
|
||||||
pipe_through [:browser, :require_authenticated_user]
|
pipe_through [:browser, :require_authenticated_user]
|
||||||
|
|
||||||
live_session :authenticated, on_mount: [{MicrowavepropWeb.UserAuth, :default}] do
|
|
||||||
live "/beacons/new", BeaconLive.Form, :new
|
|
||||||
end
|
|
||||||
|
|
||||||
live_session :admin, on_mount: [{MicrowavepropWeb.UserAuth, :require_admin}] do
|
live_session :admin, on_mount: [{MicrowavepropWeb.UserAuth, :require_admin}] do
|
||||||
live "/beacons/:id/edit", BeaconLive.Form, :edit
|
live "/beacons/:id/edit", BeaconLive.Form, :edit
|
||||||
|
|
||||||
|
live "/admin/backfill", BackfillLive
|
||||||
|
|
||||||
live "/users", UserManagementLive.Index, :index
|
live "/users", UserManagementLive.Index, :index
|
||||||
live "/users/:id/edit", UserManagementLive.Edit, :edit
|
live "/users/:id/edit", UserManagementLive.Edit, :edit
|
||||||
end
|
end
|
||||||
|
|
@ -61,10 +58,10 @@ defmodule MicrowavepropWeb.Router do
|
||||||
live "/rover", RoverLive
|
live "/rover", RoverLive
|
||||||
live "/algo", AlgoLive
|
live "/algo", AlgoLive
|
||||||
live "/about", AboutLive
|
live "/about", AboutLive
|
||||||
live "/backfill", BackfillLive
|
|
||||||
|
|
||||||
# Beacons: read-only public views
|
# Beacons: public read + submit (pending admin approval)
|
||||||
live "/beacons", BeaconLive.Index, :index
|
live "/beacons", BeaconLive.Index, :index
|
||||||
|
live "/beacons/new", BeaconLive.Form, :new
|
||||||
live "/beacons/:id", BeaconLive.Show, :show
|
live "/beacons/:id", BeaconLive.Show, :show
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
defmodule Microwaveprop.Repo.Migrations.AddNotesToBeacons do
|
||||||
|
use Ecto.Migration
|
||||||
|
|
||||||
|
def change do
|
||||||
|
alter table(:beacons) do
|
||||||
|
add :notes, :text
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -74,6 +74,13 @@ defmodule Microwaveprop.BeaconsTest do
|
||||||
assert beacon.user_id == user.id
|
assert beacon.user_id == user.id
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "anonymous submission creates a beacon with no user_id, unapproved" do
|
||||||
|
attrs = valid_beacon_attrs()
|
||||||
|
assert {:ok, %Beacon{} = beacon} = Beacons.create_beacon(nil, attrs)
|
||||||
|
assert beacon.user_id == nil
|
||||||
|
assert beacon.approved == false
|
||||||
|
end
|
||||||
|
|
||||||
test "new beacons default to unapproved" do
|
test "new beacons default to unapproved" do
|
||||||
user = user_fixture()
|
user = user_fixture()
|
||||||
assert {:ok, beacon} = Beacons.create_beacon(user, valid_beacon_attrs())
|
assert {:ok, beacon} = Beacons.create_beacon(user, valid_beacon_attrs())
|
||||||
|
|
@ -156,6 +163,13 @@ defmodule Microwaveprop.BeaconsTest do
|
||||||
assert "is invalid" in errors_on(changeset).keying
|
assert "is invalid" in errors_on(changeset).keying
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "stores notes" do
|
||||||
|
user = user_fixture()
|
||||||
|
notes = "Antenna 20 ft AGL, horizontal polarization"
|
||||||
|
{:ok, beacon} = Beacons.create_beacon(user, valid_beacon_attrs(notes: notes))
|
||||||
|
assert beacon.notes == notes
|
||||||
|
end
|
||||||
|
|
||||||
test "upcases the callsign" do
|
test "upcases the callsign" do
|
||||||
user = user_fixture()
|
user = user_fixture()
|
||||||
{:ok, beacon} = Beacons.create_beacon(user, valid_beacon_attrs(callsign: "w5hn"))
|
{:ok, beacon} = Beacons.create_beacon(user, valid_beacon_attrs(callsign: "w5hn"))
|
||||||
|
|
|
||||||
|
|
@ -36,9 +36,9 @@ defmodule MicrowavepropWeb.BeaconLiveTest do
|
||||||
refute html =~ "W5PEN"
|
refute html =~ "W5PEN"
|
||||||
end
|
end
|
||||||
|
|
||||||
test "anonymous does not see the Submit Beacon button", %{conn: conn} do
|
test "anonymous sees the Submit Beacon button", %{conn: conn} do
|
||||||
{:ok, _view, html} = live(conn, ~p"/beacons")
|
{:ok, _view, html} = live(conn, ~p"/beacons")
|
||||||
refute html =~ "Submit Beacon"
|
assert html =~ "Submit Beacon"
|
||||||
end
|
end
|
||||||
|
|
||||||
test "authenticated user sees the Submit Beacon button", %{conn: conn} do
|
test "authenticated user sees the Submit Beacon button", %{conn: conn} do
|
||||||
|
|
@ -102,8 +102,30 @@ defmodule MicrowavepropWeb.BeaconLiveTest do
|
||||||
assert html =~ "New beacon"
|
assert html =~ "New beacon"
|
||||||
end
|
end
|
||||||
|
|
||||||
test "redirects anonymous user away from /beacons/new", %{conn: conn} do
|
test "anonymous user can access /beacons/new", %{conn: conn} do
|
||||||
assert {:error, {:redirect, %{to: "/users/log-in"}}} = live(conn, ~p"/beacons/new")
|
{:ok, _view, html} = live(conn, ~p"/beacons/new")
|
||||||
|
assert html =~ "New beacon"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "anonymous submission creates an unapproved beacon with no user_id", %{conn: conn} do
|
||||||
|
{:ok, form_live, _html} = live(conn, ~p"/beacons/new")
|
||||||
|
|
||||||
|
attrs = valid_beacon_attrs(callsign: "W5ANON")
|
||||||
|
|
||||||
|
assert {:ok, _view, html} =
|
||||||
|
form_live
|
||||||
|
|> form("#beacon-form", beacon: attrs)
|
||||||
|
|> render_submit()
|
||||||
|
|> follow_redirect(conn, ~p"/beacons")
|
||||||
|
|
||||||
|
assert html =~ "awaiting admin approval"
|
||||||
|
|
||||||
|
beacon =
|
||||||
|
Enum.find(Microwaveprop.Beacons.list_pending_beacons(), fn b -> b.callsign == "W5ANON" end)
|
||||||
|
|
||||||
|
assert beacon
|
||||||
|
assert beacon.user_id == nil
|
||||||
|
assert beacon.approved == false
|
||||||
end
|
end
|
||||||
|
|
||||||
test "non-admin submission creates an unapproved beacon", %{conn: conn} do
|
test "non-admin submission creates an unapproved beacon", %{conn: conn} do
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue