diff --git a/lib/microwaveprop_web/live/rover_locations_live.ex b/lib/microwaveprop_web/live/rover_locations_live.ex index 6b57dea3..45c6f139 100644 --- a/lib/microwaveprop_web/live/rover_locations_live.ex +++ b/lib/microwaveprop_web/live/rover_locations_live.ex @@ -1,38 +1,81 @@ defmodule MicrowavepropWeb.RoverLocationsLive do @moduledoc """ Globally-shared list of rover-friendly (or off-limits) parking - locations. Everyone sees the list; only logged-in users can add or + 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(ideal off_limits) + + def table_options do + %{sorting: %{default_sort: [inserted_at: :desc]}} + end + + 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 + + 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. + def visible_query_provider(status_filter) do + base = from(l in Location, as: :resource) + + case status_filter do + "ideal" -> from(l in base, where: l.status == :ideal) + "off_limits" -> from(l in base, where: l.status == :off_limits) + _ -> base + end + end + @impl true def mount(_params, _session, socket) do {:ok, - assign(socket, + socket + |> assign( page_title: "Rover Locations", - locations: Rover.list_locations(), form: nil, editing_id: nil, - expanded_id: nil - )} + status_filter: nil + ) + |> assign(:data_provider, {__MODULE__, :visible_query_provider, [nil]})} end @impl true - def handle_event("toggle_map", %{"id" => id}, socket) do - new_id = if socket.assigns.expanded_id == id, do: nil, else: id - {:noreply, assign(socket, expanded_id: new_id)} + 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 - form = blank_form(user) - {:noreply, assign(socket, form: form, editing_id: nil)} + {:noreply, assign(socket, form: blank_form(user), editing_id: nil)} else {:noreply, put_flash(socket, :error, "Sign in to add a location.")} end @@ -41,7 +84,7 @@ defmodule MicrowavepropWeb.RoverLocationsLive do def handle_event("edit", %{"id" => id}, socket) do case current_user(socket) do %User{id: user_id} -> - case Enum.find(socket.assigns.locations, &(&1.id == id)) do + case Repo.get(Location, id) do %Location{user_id: ^user_id} = loc -> cs = Location.changeset(loc, %{}) {:noreply, assign(socket, form: to_form(cs), editing_id: id)} @@ -87,8 +130,8 @@ defmodule MicrowavepropWeb.RoverLocationsLive do {:ok, _} -> {:noreply, socket - |> assign(locations: Rover.list_locations()) - |> put_flash(:info, "Location removed.")} + |> put_flash(:info, "Location removed.") + |> reload()} {:error, :not_found} -> {:noreply, put_flash(socket, :error, "You can only delete your own locations.")} @@ -104,8 +147,9 @@ defmodule MicrowavepropWeb.RoverLocationsLive do {:ok, _loc} -> {:noreply, socket - |> assign(form: nil, editing_id: nil, locations: Rover.list_locations()) - |> put_flash(:info, "Location added.")} + |> assign(form: nil, editing_id: nil) + |> put_flash(:info, "Location added.") + |> reload()} {:error, cs} -> {:noreply, assign(socket, form: to_form(cs))} @@ -117,8 +161,9 @@ defmodule MicrowavepropWeb.RoverLocationsLive do {:ok, _loc} -> {:noreply, socket - |> assign(form: nil, editing_id: nil, locations: Rover.list_locations()) - |> put_flash(:info, "Location updated.")} + |> 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.")} @@ -128,14 +173,34 @@ defmodule MicrowavepropWeb.RoverLocationsLive do end end - defp location_for_edit(socket) do - Enum.find(socket.assigns.locations, &(&1.id == socket.assigns.editing_id)) || %Location{} + 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 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 = get_merged_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 + defp current_user(%Phoenix.LiveView.Socket{assigns: assigns}), do: scope_user(assigns) defp current_user(assigns) when is_map(assigns), do: scope_user(assigns) @@ -146,157 +211,180 @@ defmodule MicrowavepropWeb.RoverLocationsLive do end end - defp can_edit?(_assigns, %Location{user_id: nil}), do: false + defp owner?(_scope, %{user_id: nil}), do: false - defp can_edit?(assigns, %Location{user_id: uid}) do - case current_user(assigns) do + defp owner?(scope, %{user_id: uid}) do + case scope_user(%{current_scope: scope}) do %User{id: ^uid} -> true _ -> false end end - defp status_label(:ideal), do: "Ideal" - defp status_label(:off_limits), do: "Off Limits" + defp owner?(_, _), do: false - defp status_class(:ideal), do: "badge badge-success" - defp status_class(:off_limits), do: "badge badge-error" + defp actions_for(scope) do + [ + buttons: fn %{record: record} -> + row_actions(record, scope) + end + ] + end + + defp row_actions(record, scope) do + assigns = %{record: record, owner?: owner?(scope, record)} + + ~H""" +
+ + +
+ """ + end + + defp status_cell(:ideal) do + assigns = %{} + ~H|Ideal| + end + + defp status_cell(:off_limits) do + assigns = %{} + ~H|Off Limits| + end + + defp status_cell(_), do: "" + + defp location_cell(_value, %{lat: lat, lon: lon}) when is_number(lat) and is_number(lon) do + grid = Maidenhead.from_latlon(lat, lon, 10) + assigns = %{lat: lat, lon: lon, grid: grid} + + ~H""" +
+ {@grid} + + {Float.round(@lat, 5)}, {Float.round(@lon, 5)} + +
+ """ + 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|{@notes}| + 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""" - -
-
-
-

Rover Locations

-

- Community-shared parking spots for portable rover ops. -

-
+ + <.header> + Rover Locations + <:subtitle>Community-shared parking spots for portable rover ops. + <:actions> -
+ + -

- Sign in to add or edit locations. -

+

+ Sign in to add or edit locations. +

-
-

- {if @editing_id, do: "Edit location", else: "New location"} -

- <.form for={@form} phx-change="validate" phx-submit="save" id="location-form"> -
- <.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" - /> - <.input - field={@form[:status]} - type="select" - label="Status" - options={[{"Ideal", :ideal}, {"Off Limits", :off_limits}]} - /> -
+
+

+ {if @editing_id, do: "Edit location", else: "New location"} +

+ <.form for={@form} phx-change="validate" phx-submit="save" id="location-form"> +
<.input - field={@form[:notes]} - type="textarea" - label="Notes" - rows="3" - placeholder="Trail access, line-of-sight notes, contact info, etc." + field={@form[:lat]} + type="number" + step="any" + label="Latitude" + placeholder="32.5" /> -
- - -
- -
- -
- No locations yet. Be the first to share one. -
- -
    -
  • -
    -
    -
    - {status_label(loc.status)} - - {Maidenhead.from_latlon(loc.lat, loc.lon, 10)} - - - {Float.round(loc.lat, 5)}, {Float.round(loc.lon, 5)} - -
    -

    - {loc.notes} -

    -

    - Added {Calendar.strftime(loc.inserted_at, "%Y-%m-%d")} -

    -
    -
    - - -
    -
    -
    -
    -
  • -
+ <.input + field={@form[:lon]} + type="number" + step="any" + label="Longitude" + placeholder="-97.5" + /> + <.input + field={@form[:status]} + type="select" + label="Status" + options={[{"Ideal", :ideal}, {"Off Limits", :off_limits}]} + /> +
+ <.input + field={@form[:notes]} + type="textarea" + label="Notes" + rows="3" + placeholder="Trail access, line-of-sight notes, contact info, etc." + /> +
+ + +
+
+ +
+ + +
+ + <.live_table + fields={fields()} + filters={filters()} + options={@options} + streams={@streams} + actions={actions_for(@current_scope)} + /> """ end diff --git a/test/microwaveprop_web/live/rover_locations_live_test.exs b/test/microwaveprop_web/live/rover_locations_live_test.exs index 82771733..91d5d153 100644 --- a/test/microwaveprop_web/live/rover_locations_live_test.exs +++ b/test/microwaveprop_web/live/rover_locations_live_test.exs @@ -33,6 +33,32 @@ defmodule MicrowavepropWeb.RoverLocationsLiveTest do assert html =~ ~s(disabled) assert html =~ "Add location" end + + test "status filter narrows the visible rows", %{conn: conn} do + user = Microwaveprop.AccountsFixtures.user_fixture() + {:ok, _} = Rover.create_location(user, %{lat: 32.0, lon: -97.0, status: :ideal, notes: "ideal-spot"}) + {:ok, _} = Rover.create_location(user, %{lat: 33.0, lon: -98.0, status: :off_limits, notes: "no-go-spot"}) + + {:ok, lv, html} = live(conn, ~p"/rover-locations") + assert html =~ "ideal-spot" + assert html =~ "no-go-spot" + + html = + lv + |> form("form[phx-change=filter_status]", %{"value" => "ideal"}) + |> render_change() + + assert html =~ "ideal-spot" + refute html =~ "no-go-spot" + + html = + lv + |> form("form[phx-change=filter_status]", %{"value" => "off_limits"}) + |> render_change() + + refute html =~ "ideal-spot" + assert html =~ "no-go-spot" + end end describe "logged-in user" do @@ -41,7 +67,7 @@ defmodule MicrowavepropWeb.RoverLocationsLiveTest do test "can add a new location", %{conn: conn} do {:ok, lv, _html} = live(conn, ~p"/rover-locations") - lv |> element("button", "+ Add location") |> render_click() + lv |> element("button", "Add location") |> render_click() lv |> form("#location-form", @@ -53,19 +79,17 @@ defmodule MicrowavepropWeb.RoverLocationsLiveTest do assert html =~ "test note" end - test "can edit and delete only their own location", %{conn: conn, user: user} do + test "edit/delete buttons appear only on the owner's row", %{conn: conn, user: user} do other = Microwaveprop.AccountsFixtures.user_fixture() {:ok, mine} = Rover.create_location(user, %{lat: 32.0, lon: -97.0, status: :ideal}) {:ok, theirs} = Rover.create_location(other, %{lat: 33.0, lon: -98.0, status: :off_limits}) - {:ok, lv, html} = live(conn, ~p"/rover-locations") + {:ok, lv, _html} = live(conn, ~p"/rover-locations") - assert html =~ "location-#{mine.id}" - assert html =~ "location-#{theirs.id}" - - # Edit/Delete buttons rendered for owned location only. - assert has_element?(lv, "#location-#{mine.id} button", "Edit") - refute has_element?(lv, "#location-#{theirs.id} button", "Edit") + assert has_element?(lv, "button[phx-click=edit][phx-value-id='#{mine.id}']", "Edit") + assert has_element?(lv, "button[phx-click=delete][phx-value-id='#{mine.id}']", "Delete") + refute has_element?(lv, "button[phx-click=edit][phx-value-id='#{theirs.id}']", "Edit") + refute has_element?(lv, "button[phx-click=delete][phx-value-id='#{theirs.id}']", "Delete") end end end