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:
parent
8fd9759e4e
commit
89a5cfefd2
3 changed files with 242 additions and 11 deletions
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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})
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue