feat(rover-locations): drag-to-edit marker with live grid+coord preview

Owner / admin click "Edit" on /rover-locations/:id and the marker
becomes draggable. As they drag, the JS hook pushes `location_dragged`
events with the new lat/lon; the LiveView updates `working_lat` /
`working_lon` / `grid` so the page shows the live preview without
writing to the DB. "Save" persists via Rover.update_location, "Cancel"
reverts both the assigns and the marker position.

Implementation notes:
- Switched the map marker from L.circleMarker to a draggable L.marker
  with a divIcon (CircleMarker has no `dragging` handler).
- A second `draggingDotIcon` (amber, larger, grab cursor) makes the
  edit affordance obvious.
- Server <-> hook coordination uses push_event:
  set_marker_draggable / reset_marker. Drag results come back on
  pushEvent("location_dragged", { lat, lon }).
- Coordinates row binds to working_lat/working_lon so the displayed
  decimals + derived grid update on every drag, not only on save.
This commit is contained in:
Graham McIntire 2026-05-03 12:51:12 -05:00
parent 8fd9759e4e
commit 89a5cfefd2
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
3 changed files with 242 additions and 11 deletions

View file

@ -3,12 +3,31 @@ import { updateGridOverlay } from "./maidenhead_grid"
interface LocationMapHook extends ViewHook {
map: L.Map
marker: L.CircleMarker
marker: L.Marker
gridLayer: L.LayerGroup
gridVisible: boolean
visibilityHandler: (() => void) | null
}
// Same red dot the old CircleMarker rendered, recreated as a divIcon so
// we can drop it on a real L.Marker (CircleMarker has no `dragging`
// handler and can't be made draggable).
const dotIcon = L.divIcon({
html:
'<div style="width:16px;height:16px;border-radius:50%;background:#ef4444;border:2px solid #fff;box-shadow:0 0 4px rgba(0,0,0,.4)"></div>',
className: "rover-location-dot",
iconSize: [16, 16],
iconAnchor: [8, 8]
})
const draggingDotIcon = L.divIcon({
html:
'<div style="width:18px;height:18px;border-radius:50%;background:#f59e0b;border:2px solid #fff;box-shadow:0 0 6px rgba(0,0,0,.5);cursor:grab"></div>',
className: "rover-location-dot rover-location-dot-edit",
iconSize: [18, 18],
iconAnchor: [9, 9]
})
export const LocationMap: Partial<LocationMapHook> = {
mounted(this: LocationMapHook) {
const lat = parseFloat(this.el.dataset.lat || "0")
@ -41,16 +60,23 @@ export const LocationMap: Partial<LocationMapHook> = {
{ position: "topright", collapsed: false }
).addTo(map)
this.marker = L.circleMarker([lat, lon], {
radius: 8,
color: "#ffffff",
weight: 2,
fillColor: "#ef4444",
fillOpacity: 0.95,
pane: "markerPane"
}).addTo(map)
// Draggable Marker (not CircleMarker — that one can't be dragged).
// Server flips draggability via `set_marker_draggable` push_event.
this.marker = L.marker([lat, lon], { icon: dotIcon, draggable: false }).addTo(map)
this.marker.bindPopup(`${lat.toFixed(6)}, ${lon.toFixed(6)}`)
this.marker.on("drag", (e: L.LeafletEvent) => {
const ll = (e.target as L.Marker).getLatLng()
this.marker.setPopupContent(`${ll.lat.toFixed(6)}, ${ll.lng.toFixed(6)}`)
})
// dragend pushes the new coords up so the LiveView can update its
// `working_lat` / `working_lon` and render the live grid preview.
this.marker.on("dragend", (e: L.DragEndEvent) => {
const ll = (e.target as L.Marker).getLatLng()
this.pushEvent("location_dragged", { lat: ll.lat, lon: ll.lng })
})
// Maidenhead grid overlay + toggle
this.gridLayer = L.layerGroup().addTo(map)
this.gridVisible = true
@ -84,6 +110,22 @@ export const LocationMap: Partial<LocationMapHook> = {
})
new GridToggle().addTo(map)
// Server → hook events for edit mode.
this.handleEvent("set_marker_draggable", ({ draggable }: { draggable: boolean }) => {
if (draggable) {
this.marker.setIcon(draggingDotIcon)
this.marker.dragging?.enable()
} else {
this.marker.setIcon(dotIcon)
this.marker.dragging?.disable()
}
})
this.handleEvent("reset_marker", ({ lat, lon }: { lat: number; lon: number }) => {
this.marker.setLatLng([lat, lon])
this.marker.setPopupContent(`${lat.toFixed(6)}, ${lon.toFixed(6)}`)
})
// LiveView reconnect / tab visibility — Leaflet needs invalidateSize.
this.visibilityHandler = () => {
if (document.visibilityState === "visible") this.map.invalidateSize()

View file

@ -16,6 +16,9 @@ defmodule MicrowavepropWeb.RoverLocationsLive.Show do
assign(socket,
page_title: "Rover Location",
location: loc,
editing: false,
working_lat: loc.lat,
working_lon: loc.lon,
grid: Maidenhead.from_latlon(loc.lat, loc.lon, 10)
)}
@ -28,6 +31,76 @@ defmodule MicrowavepropWeb.RoverLocationsLive.Show do
end
@impl true
def handle_event("toggle_edit", _params, socket) do
if can_modify?(socket.assigns[:current_scope], socket.assigns.location) do
{:noreply,
socket
|> assign(editing: true)
|> push_event("set_marker_draggable", %{draggable: true})}
else
{:noreply, put_flash(socket, :error, "You can only edit your own locations.")}
end
end
def handle_event("cancel_edit", _params, socket) do
loc = socket.assigns.location
{:noreply,
socket
|> assign(
editing: false,
working_lat: loc.lat,
working_lon: loc.lon,
grid: Maidenhead.from_latlon(loc.lat, loc.lon, 10)
)
|> push_event("reset_marker", %{lat: loc.lat, lon: loc.lon})
|> push_event("set_marker_draggable", %{draggable: false})}
end
# Hook fires this on `dragend` with the marker's new coordinates.
# We only update the working preview — persistence waits for save.
def handle_event("location_dragged", %{"lat" => lat, "lon" => lon}, socket) when is_number(lat) and is_number(lon) do
{:noreply,
assign(socket,
working_lat: lat,
working_lon: lon,
grid: Maidenhead.from_latlon(lat, lon, 10)
)}
end
def handle_event("save_edit", _params, socket) do
case current_user(socket.assigns[:current_scope]) do
%User{} = user ->
attrs = %{lat: socket.assigns.working_lat, lon: socket.assigns.working_lon}
case Rover.update_location(user, socket.assigns.location.id, attrs) do
{:ok, updated} ->
updated = Repo.preload(updated, :user)
{:noreply,
socket
|> assign(
location: updated,
editing: false,
working_lat: updated.lat,
working_lon: updated.lon,
grid: Maidenhead.from_latlon(updated.lat, updated.lon, 10)
)
|> put_flash(:info, "Location updated.")
|> push_event("set_marker_draggable", %{draggable: false})}
{:error, :not_found} ->
{:noreply, put_flash(socket, :error, "You can only edit your own locations.")}
{:error, %Ecto.Changeset{}} ->
{:noreply, put_flash(socket, :error, "Could not save those coordinates.")}
end
_ ->
{:noreply, put_flash(socket, :error, "Sign in required.")}
end
end
def handle_event("delete", _params, socket) do
case current_user(socket.assigns[:current_scope]) do
%User{} = user ->
@ -86,13 +159,42 @@ defmodule MicrowavepropWeb.RoverLocationsLive.Show do
<:subtitle>
<span class={status_class(@location.status)}>{status_label(@location.status)}</span>
{@grid}
<span :if={@editing} class="ml-2 badge badge-warning">Editing drag the marker</span>
</:subtitle>
<:actions>
<.link navigate={~p"/rover-locations"} class="btn btn-ghost btn-sm">
<.icon name="hero-arrow-left" class="w-4 h-4" /> Back
</.link>
<button
:if={can_modify?(@current_scope, @location)}
:if={can_modify?(@current_scope, @location) and not @editing}
type="button"
phx-click="toggle_edit"
class="btn btn-ghost btn-sm"
>
<.icon name="hero-pencil-square" class="w-4 h-4" /> Edit
</button>
<button
:if={@editing}
type="button"
phx-click="save_edit"
class="btn btn-primary btn-sm"
>
<.icon name="hero-check" class="w-4 h-4" /> Save
</button>
<button
:if={@editing}
type="button"
phx-click="cancel_edit"
class="btn btn-ghost btn-sm"
>
Cancel
</button>
<button
:if={can_modify?(@current_scope, @location) and not @editing}
type="button"
phx-click="delete"
data-confirm="Delete this location?"
@ -112,7 +214,7 @@ defmodule MicrowavepropWeb.RoverLocationsLive.Show do
<div>
<dt class="text-base-content/60">Coordinates</dt>
<dd class="font-mono">
{Float.round(@location.lat, 6)}, {Float.round(@location.lon, 6)}
{Float.round(@working_lat, 6)}, {Float.round(@working_lon, 6)}
</dd>
</div>
<div>

View file

@ -3,6 +3,8 @@ defmodule MicrowavepropWeb.RoverLocationsLive.ShowTest do
import Phoenix.LiveViewTest
alias Microwaveprop.Radio.Maidenhead
alias Microwaveprop.Repo
alias Microwaveprop.Rover
describe "show" do
@ -62,6 +64,91 @@ defmodule MicrowavepropWeb.RoverLocationsLive.ShowTest do
assert html =~ "Delete"
end
test "owner can enter edit mode and the marker is told to become draggable",
%{conn: conn} do
user = Microwaveprop.AccountsFixtures.user_fixture()
{:ok, loc} = Rover.create_location(user, %{lat: 32.5, lon: -97.5, status: :good})
conn = log_in_user(conn, user)
{:ok, lv, _html} = live(conn, ~p"/rover-locations/#{loc.id}")
html = lv |> element("button", "Edit") |> render_click()
assert html =~ "Editing — drag the marker"
# Save + Cancel show up; Edit button hides.
assert has_element?(lv, "button[phx-click='save_edit']")
assert has_element?(lv, "button[phx-click='cancel_edit']")
refute has_element?(lv, "button[phx-click='toggle_edit']")
end
test "drag updates the working coords + grid (without persisting)", %{conn: conn} do
user = Microwaveprop.AccountsFixtures.user_fixture()
{:ok, loc} = Rover.create_location(user, %{lat: 32.5, lon: -97.5, status: :good})
conn = log_in_user(conn, user)
{:ok, lv, _html} = live(conn, ~p"/rover-locations/#{loc.id}")
_ = lv |> element("button", "Edit") |> render_click()
# Simulate the JS hook firing the dragend event.
html = render_hook(lv, "location_dragged", %{"lat" => 33.25, "lon" => -97.75})
assert html =~ "33.25"
assert html =~ "-97.75"
# Grid recomputed for the new coords.
assert html =~ Maidenhead.from_latlon(33.25, -97.75, 10)
# DB still has the original coords until Save is clicked.
assert %{lat: 32.5, lon: -97.5} = Repo.reload(loc)
end
test "save persists the dragged coords to the DB", %{conn: conn} do
user = Microwaveprop.AccountsFixtures.user_fixture()
{:ok, loc} = Rover.create_location(user, %{lat: 32.5, lon: -97.5, status: :good})
conn = log_in_user(conn, user)
{:ok, lv, _html} = live(conn, ~p"/rover-locations/#{loc.id}")
_ = lv |> element("button", "Edit") |> render_click()
_ = render_hook(lv, "location_dragged", %{"lat" => 33.25, "lon" => -97.75})
html = lv |> element("button[phx-click='save_edit']") |> render_click()
assert html =~ "Location updated"
reloaded = Repo.reload(loc)
assert_in_delta reloaded.lat, 33.25, 1.0e-6
assert_in_delta reloaded.lon, -97.75, 1.0e-6
end
test "cancel reverts the working coords without writing to the DB",
%{conn: conn} do
user = Microwaveprop.AccountsFixtures.user_fixture()
{:ok, loc} = Rover.create_location(user, %{lat: 32.5, lon: -97.5, status: :good})
conn = log_in_user(conn, user)
{:ok, lv, _html} = live(conn, ~p"/rover-locations/#{loc.id}")
_ = lv |> element("button", "Edit") |> render_click()
_ = render_hook(lv, "location_dragged", %{"lat" => 99.0, "lon" => 99.0})
html = lv |> element("button[phx-click='cancel_edit']") |> render_click()
# Working coords reset to the stored values.
assert html =~ "32.5"
refute html =~ "99.0, 99.0"
reloaded = Repo.reload(loc)
assert reloaded.lat == 32.5
assert reloaded.lon == -97.5
end
test "non-owner does not see the Edit button", %{conn: conn} do
owner = Microwaveprop.AccountsFixtures.user_fixture()
visitor = Microwaveprop.AccountsFixtures.user_fixture()
{:ok, loc} = Rover.create_location(owner, %{lat: 32.5, lon: -97.5, status: :good})
conn = log_in_user(conn, visitor)
{:ok, lv, _html} = live(conn, ~p"/rover-locations/#{loc.id}")
refute has_element?(lv, "button[phx-click='toggle_edit']")
end
test "owner can delete from the show page and is redirected", %{conn: conn} do
user = Microwaveprop.AccountsFixtures.user_fixture()
{:ok, loc} = Rover.create_location(user, %{lat: 32.5, lon: -97.5, status: :good})