neiam
/apollos_crib
It's Apollo's Crib
public · Issues · Pulls 1 · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
Log in to mark this repository.
Add bulk paint, and put a map and a route-lines toggle in the tester
568e93a · Gabriel Morell · 2026-08-10 22:11
Message
{commit_body(@commit)}
Files changed
modified
apps/room_sanctum/lib/room_sanctum_web/live/cfg_query_live/index.ex
+26
−0
@@ -30,9 +30,35 @@ defmodule RoomSanctumWeb.QueryLive.Index do
| 30 | 30 | |> assign(:available_tints, available_tints) |
| 31 | 31 | |> assign(:queries, queries) |
| 32 | 32 | |> assign(:vehicle_positions, []) |
| 33 | + |> assign(:show_route_lines, false) | |
| 34 | + |> assign(:route_lines, []) | |
| 33 | 35 | |> stream(:cfg_queries, queries)} |
| 34 | 36 | end |
| 35 | 37 | |
| 38 | + # The index spans sources, so this covers every GTFS source with a query on | |
| 39 | + # the map. Built on first use and then kept. | |
| 40 | + @impl true | |
| 41 | + def handle_event("toggle-route-lines", _params, socket) do | |
| 42 | + showing? = not socket.assigns.show_route_lines | |
| 43 | + | |
| 44 | + lines = | |
| 45 | + case {showing?, socket.assigns.route_lines} do | |
| 46 | + {true, []} -> | |
| 47 | + socket.assigns.queries | |
| 48 | + |> Enum.filter(&(&1.source && &1.source.type == :gtfs)) | |
| 49 | + |> Enum.map(& &1.source_id) | |
| 50 | + |> RoomSanctum.Storage.list_route_lines() | |
| 51 | + | |
| 52 | + {_, existing} -> | |
| 53 | + existing | |
| 54 | + end | |
| 55 | + | |
| 56 | + {:noreply, | |
| 57 | + socket | |
| 58 | + |> assign(:show_route_lines, showing?) | |
| 59 | + |> assign(:route_lines, lines)} | |
| 60 | + end | |
| 61 | + | |
| 36 | 62 | @impl true |
| 37 | 63 | def handle_params(params, _url, socket) do |
| 38 | 64 | {:noreply, apply_action(socket, socket.assigns.live_action, params)} |
modified
apps/room_sanctum/lib/room_sanctum_web/live/cfg_query_live/index.html.heex
+3
−0
@@ -44,6 +44,9 @@
| 44 | 44 | <.query_geospatial_map |
| 45 | 45 | queries={@queries} |
| 46 | 46 | vehicle_positions={@vehicle_positions} |
| 47 | + route_lines={if @show_route_lines, do: @route_lines, else: []} | |
| 48 | + route_lines_event={if Enum.any?(@queries, &(&1.source && &1.source.type == :gtfs)), do: "toggle-route-lines"} | |
| 49 | + show_route_lines={@show_route_lines} | |
| 47 | 50 | selected_tint={@tint} |
| 48 | 51 | class="" |
| 49 | 52 | height="600px" |
modified
apps/room_sanctum/lib/room_sanctum_web/live/cfg_query_live/show.ex
+21
−1
@@ -19,7 +19,27 @@ defmodule RoomSanctumWeb.QueryLive.Show do
| 19 | 19 | {:ok, socket |
| 20 | 20 | |> assign(:preview, []) |
| 21 | 21 | |> assign(:preview_mode, :raw) |
| 22 | − |> assign(:vehicle_positions, [])} | |
| 22 | + |> assign(:vehicle_positions, []) | |
| 23 | + |> assign(:show_route_lines, false) | |
| 24 | + |> assign(:route_lines, [])} | |
| 25 | + end | |
| 26 | + | |
| 27 | + # Built on first use and then kept: the geometry query is not cheap enough to | |
| 28 | + # repeat every time the layer is switched back on. | |
| 29 | + @impl true | |
| 30 | + def handle_event("toggle-route-lines", _params, socket) do | |
| 31 | + showing? = not socket.assigns.show_route_lines | |
| 32 | + | |
| 33 | + lines = | |
| 34 | + case {showing?, socket.assigns.route_lines} do | |
| 35 | + {true, []} -> RoomSanctum.Storage.list_route_lines([socket.assigns.query.source_id]) | |
| 36 | + {_, existing} -> existing | |
| 37 | + end | |
| 38 | + | |
| 39 | + {:noreply, | |
| 40 | + socket | |
| 41 | + |> assign(:show_route_lines, showing?) | |
| 42 | + |> assign(:route_lines, lines)} | |
| 23 | 43 | end |
| 24 | 44 | |
| 25 | 45 | @impl true |
modified
apps/room_sanctum/lib/room_sanctum_web/live/cfg_query_live/show.html.heex
+3
−0
@@ -123,6 +123,9 @@
| 123 | 123 | <.query_geospatial_map |
| 124 | 124 | queries={[@query]} |
| 125 | 125 | vehicle_positions={@vehicle_positions} |
| 126 | + route_lines={if @show_route_lines, do: @route_lines, else: []} | |
| 127 | + route_lines_event={if @query.source && @query.source.type == :gtfs, do: "toggle-route-lines"} | |
| 128 | + show_route_lines={@show_route_lines} | |
| 126 | 129 | selected_tint={if @query.meta && @query.meta.tint, do: @query.meta.tint, else: nil} |
| 127 | 130 | class="" |
| 128 | 131 | height="350px" |
modified
apps/room_sanctum/lib/room_sanctum_web/live/cfg_source_live/show.ex
+169
−6
@@ -28,7 +28,24 @@ defmodule RoomSanctumWeb.SourceLive.Show do
| 28 | 28 | |> assign(:vehicle_positions, []) |
| 29 | 29 | |> assign(:free_bikes, []) |
| 30 | 30 | |> assign(:stations, []) |
| 31 | + # Both are otherwise only set by the :update_sec tick 200ms in, so any | |
| 32 | + # view touching them could be rendered before they exist. | |
| 33 | + |> assign(:station_statuses, []) | |
| 34 | + |> assign(:source_tint, nil) | |
| 35 | + # Route geometry is off until asked for: building it walks stop_times, | |
| 36 | + # and on a full feed it is hundreds of polylines. | |
| 37 | + |> assign(:show_route_lines, false) | |
| 38 | + |> assign(:route_lines, []) | |
| 39 | + |> assign(:route_types, %{}) | |
| 40 | + # Route ids calling at the stop the tester has selected, so its map can | |
| 41 | + # show that stop's routes rather than the whole system's. | |
| 42 | + |> assign(:tester_route_ids, []) | |
| 31 | 43 | |> assign(:view_mode, :system) |
| 44 | + # Same palette the query form offers, so a tint picked here and one | |
| 45 | + # picked there are drawn from the same set. Every entry needs its | |
| 46 | + # bg-<tint>-500 to survive Tailwind's purge -- the literal swatches in | |
| 47 | + # cfg_query_live/form_component.html.heex are what keep them alive. | |
| 48 | + |> assign(:tint_opts, ~w(amber lime emerald sky violet fuchsia rose stone slate)) | |
| 32 | 49 | |> assign(:gitlab_config_open, false) |
| 33 | 50 | |> assign(:gitlab_available_projects, []) |
| 34 | 51 | |> assign(:github_config_open, false) |
@@ -69,6 +86,13 @@ defmodule RoomSanctumWeb.SourceLive.Show do
| 69 | 86 | _ -> [] |
| 70 | 87 | end |
| 71 | 88 | |
| 89 | + # Vehicle positions name a route, not a vehicle kind; the type comes from | |
| 90 | + # the route table. | |
| 91 | + route_types = case socket.assigns.source.type do | |
| 92 | + :gtfs -> Storage.route_types(socket.assigns.source_id) | |
| 93 | + _ -> %{} | |
| 94 | + end | |
| 95 | + | |
| 72 | 96 | # Extract source tint for stations |
| 73 | 97 | source_tint = if socket.assigns.source.meta && socket.assigns.source.meta.tint do |
| 74 | 98 | socket.assigns.source.meta.tint |
@@ -82,6 +106,7 @@ defmodule RoomSanctumWeb.SourceLive.Show do
| 82 | 106 | |> assign(:free_bikes, free_bikes) |
| 83 | 107 | |> assign(:stations, stations) |
| 84 | 108 | |> assign(:station_statuses, station_statuses) |
| 109 | + |> assign(:route_types, route_types) | |
| 85 | 110 | |> assign(:source_tint, source_tint)} |
| 86 | 111 | end |
| 87 | 112 |
@@ -117,16 +142,21 @@ defmodule RoomSanctumWeb.SourceLive.Show do
| 117 | 142 | _default -> :ok |
| 118 | 143 | end |
| 119 | 144 | |
| 145 | + source_id = String.to_integer(id) | |
| 146 | + | |
| 147 | + # Queries were previously only loaded by the :update_sec tick 200ms after | |
| 148 | + # mount, so the card rendered empty on arrival -- which the bulk paint view | |
| 149 | + # would announce as "no queries yet" before correcting itself. | |
| 150 | + queries = Configuration.get_queries(:source, source_id) | |
| 151 | + | |
| 120 | 152 | { |
| 121 | 153 | :noreply, |
| 122 | 154 | socket |
| 123 | 155 | |> assign(:page_title, page_title(socket.assigns.live_action)) |
| 124 | 156 | |> assign(:source, source) |
| 125 | − |> assign( | |
| 126 | − :source_id, | |
| 127 | − id | |
| 128 | − |> String.to_integer() | |
| 129 | − ) | |
| 157 | + |> assign(:source_id, source_id) | |
| 158 | + |> assign(:queries, queries) | |
| 159 | + |> assign(:available_tints, get_available_tints(queries)) | |
| 130 | 160 | } |
| 131 | 161 | end |
| 132 | 162 |
@@ -685,7 +715,17 @@ defmodule RoomSanctumWeb.SourceLive.Show do
| 685 | 715 | |
| 686 | 716 | @impl true |
| 687 | 717 | def handle_event("pick-result", %{"id" => val, "name" => name}, socket) do |
| 688 | − {:noreply, socket |> assign(:tester_selected, val) |> assign(:tester_selected_name, name)} | |
| 718 | + serving = | |
| 719 | + case socket.assigns.source.type do | |
| 720 | + :gtfs -> Storage.routes_serving_stop(socket.assigns.source_id, val) | |
| 721 | + _ -> [] | |
| 722 | + end | |
| 723 | + | |
| 724 | + {:noreply, | |
| 725 | + socket | |
| 726 | + |> assign(:tester_selected, val) | |
| 727 | + |> assign(:tester_selected_name, name) | |
| 728 | + |> assign(:tester_route_ids, serving)} | |
| 689 | 729 | end |
| 690 | 730 | |
| 691 | 731 | @impl true |
@@ -845,10 +885,63 @@ defmodule RoomSanctumWeb.SourceLive.Show do
| 845 | 885 | new_mode = case socket.assigns.view_mode do |
| 846 | 886 | :system -> :detail |
| 847 | 887 | :detail -> :system |
| 888 | + # Leaving bulk paint by the other button lands on the map, which is | |
| 889 | + # where the tints just set are actually visible. | |
| 890 | + :paint -> :detail | |
| 848 | 891 | end |
| 849 | 892 | {:noreply, socket |> assign(:view_mode, new_mode)} |
| 850 | 893 | end |
| 851 | 894 | |
| 895 | + # Built on first use and then kept: the query is not cheap enough to repeat | |
| 896 | + # every time the layer is switched back on. | |
| 897 | + def handle_event("toggle-route-lines", _params, socket) do | |
| 898 | + showing? = not socket.assigns.show_route_lines | |
| 899 | + | |
| 900 | + lines = | |
| 901 | + case {showing?, socket.assigns.route_lines} do | |
| 902 | + {true, []} -> Storage.list_route_lines(socket.assigns.source_id) | |
| 903 | + {_, existing} -> existing | |
| 904 | + end | |
| 905 | + | |
| 906 | + {:noreply, | |
| 907 | + socket | |
| 908 | + |> assign(:show_route_lines, showing?) | |
| 909 | + |> assign(:route_lines, lines)} | |
| 910 | + end | |
| 911 | + | |
| 912 | + def handle_event("toggle-paint", _params, socket) do | |
| 913 | + new_mode = if socket.assigns.view_mode == :paint, do: :system, else: :paint | |
| 914 | + {:noreply, socket |> assign(:view_mode, new_mode)} | |
| 915 | + end | |
| 916 | + | |
| 917 | + # Painting writes through immediately rather than collecting a form: the | |
| 918 | + # point of the view is working down a list of queries, and a save step you | |
| 919 | + # can forget would silently lose the whole pass. | |
| 920 | + def handle_event("paint-query", %{"query-id" => id} = params, socket) do | |
| 921 | + tint = case Map.get(params, "tint") do | |
| 922 | + "" -> nil | |
| 923 | + value -> value | |
| 924 | + end | |
| 925 | + | |
| 926 | + query = Enum.find(socket.assigns.queries, &(to_string(&1.id) == to_string(id))) | |
| 927 | + | |
| 928 | + case query && Configuration.update_query(query, %{"meta" => %{"tint" => tint}}) do | |
| 929 | + {:ok, _updated} -> | |
| 930 | + queries = Configuration.get_queries(:source, socket.assigns.source_id) | |
| 931 | + | |
| 932 | + {:noreply, | |
| 933 | + socket | |
| 934 | + |> assign(:queries, queries) | |
| 935 | + |> assign(:available_tints, get_available_tints(queries))} | |
| 936 | + | |
| 937 | + {:error, _changeset} -> | |
| 938 | + {:noreply, socket |> put_flash(:error, "Could not set that tint")} | |
| 939 | + | |
| 940 | + nil -> | |
| 941 | + {:noreply, socket} | |
| 942 | + end | |
| 943 | + end | |
| 944 | + | |
| 852 | 945 | defp percent(num, denom) do |
| 853 | 946 | (num / denom * 100) |
| 854 | 947 | |> Float.floor() |
@@ -1051,6 +1144,76 @@ defmodule RoomSanctumWeb.SourceLive.Show do
| 1051 | 1144 | coords |> Tuple.to_list() |> Poison.encode!() |
| 1052 | 1145 | end |
| 1053 | 1146 | |
| 1147 | + # How far either side of the picked stop the tester map shows its | |
| 1148 | + # neighbours. Roughly a kilometre, which is enough to place a stop on its | |
| 1149 | + # street without dragging in the rest of the system. | |
| 1150 | + @tester_area_degrees 0.01 | |
| 1151 | + | |
| 1152 | + # Everything the map draws carries its position differently: GBFS stations | |
| 1153 | + # put a Geo.Point in :place, free bikes in :point, GTFS stops are normalised | |
| 1154 | + # to :lat/:lon by stop_as_station/1, and realtime vehicles use the spelled | |
| 1155 | + # out :latitude/:longitude. | |
| 1156 | + defp map_coords(item) do | |
| 1157 | + cond do | |
| 1158 | + match?(%Geo.Point{}, Map.get(item, :place)) -> from_geo(Map.get(item, :place)) | |
| 1159 | + match?(%Geo.Point{}, Map.get(item, :point)) -> from_geo(Map.get(item, :point)) | |
| 1160 | + pair(item, :lat, :lon) -> pair(item, :lat, :lon) | |
| 1161 | + pair(item, :latitude, :longitude) -> pair(item, :latitude, :longitude) | |
| 1162 | + true -> nil | |
| 1163 | + end | |
| 1164 | + end | |
| 1165 | + | |
| 1166 | + defp from_geo(%Geo.Point{coordinates: {lon, lat}}), do: {lat, lon} | |
| 1167 | + | |
| 1168 | + defp pair(item, lat_key, lon_key) do | |
| 1169 | + lat = Map.get(item, lat_key) | |
| 1170 | + lon = Map.get(item, lon_key) | |
| 1171 | + if is_number(lat) and is_number(lon), do: {lat, lon}, else: nil | |
| 1172 | + end | |
| 1173 | + | |
| 1174 | + defp tester_focus_station(_stations, nil), do: nil | |
| 1175 | + | |
| 1176 | + defp tester_focus_station(stations, selected_id) do | |
| 1177 | + Enum.find(stations, fn station -> | |
| 1178 | + to_string(station.station_id) == to_string(selected_id) | |
| 1179 | + end) | |
| 1180 | + end | |
| 1181 | + | |
| 1182 | + # The tester is looking at one stop, so the whole feed's geometry is noise: | |
| 1183 | + # keep only the routes that call there. A pure filter over lines already | |
| 1184 | + # loaded, so it costs nothing on the tester's 2s re-render. | |
| 1185 | + defp lines_for_stop(lines, route_ids) do | |
| 1186 | + serving = MapSet.new(route_ids) | |
| 1187 | + Enum.filter(lines, &MapSet.member?(serving, &1.id)) | |
| 1188 | + end | |
| 1189 | + | |
| 1190 | + defp within_area(items, {lat, lon}) do | |
| 1191 | + Enum.filter(items, fn item -> | |
| 1192 | + case map_coords(item) do | |
| 1193 | + {ilat, ilon} -> | |
| 1194 | + abs(ilat - lat) <= @tester_area_degrees and abs(ilon - lon) <= @tester_area_degrees | |
| 1195 | + | |
| 1196 | + nil -> | |
| 1197 | + false | |
| 1198 | + end | |
| 1199 | + end) | |
| 1200 | + end | |
| 1201 | + | |
| 1202 | + # The radio itself is sr-only, so the swatch carries the whole selected/not | |
| 1203 | + # signal. A ring alone would be easy to miss against nine circles, hence the | |
| 1204 | + # dimming of the unselected ones as well as the check drawn inside the | |
| 1205 | + # chosen swatch. | |
| 1206 | + defp swatch_class(selected?) do | |
| 1207 | + base = "flex items-center justify-center w-7 h-7 rounded-full" | |
| 1208 | + | |
| 1209 | + case selected? do | |
| 1210 | + true -> base <> " ring-2 ring-offset-2 ring-offset-base-100 ring-base-content" | |
| 1211 | + # Dimmed enough to recede behind the selected swatch, but not so far that | |
| 1212 | + # the colours stop being tellable apart -- picking one is the whole job. | |
| 1213 | + _ -> base <> " opacity-70 hover:opacity-100" | |
| 1214 | + end | |
| 1215 | + end | |
| 1216 | + | |
| 1054 | 1217 | defp get_available_tints(queries) do |
| 1055 | 1218 | queries |
| 1056 | 1219 | |> Enum.flat_map(fn query -> |
modified
apps/room_sanctum/lib/room_sanctum_web/live/cfg_source_live/show.html.heex
+222
−42
@@ -325,7 +325,15 @@ gl_available_projects={@gitlab_available_projects}
| 325 | 325 | <i class={"fa-solid #{if @view_mode == :system, do: "fa-map", else: "fa-table"} mr-1"}></i> |
| 326 | 326 | <%= if @view_mode == :system, do: "Map View", else: "System View" %> |
| 327 | 327 | </button> |
| 328 | − | |
| 328 | + | |
| 329 | + <button | |
| 330 | + class={"btn btn-sm text-xs #{if @view_mode == :paint, do: "btn-primary", else: "btn-outline"}"} | |
| 331 | + phx-click="toggle-paint" | |
| 332 | + > | |
| 333 | + <i class="fa-solid fa-palette mr-1"></i> | |
| 334 | + Bulk Paint | |
| 335 | + </button> | |
| 336 | + | |
| 329 | 337 | <!-- Available tints - clickable filters --> |
| 330 | 338 | <span class="text-amber-500"></span> |
| 331 | 339 | <span class="text-lime-500"></span> |
@@ -352,14 +360,19 @@ gl_available_projects={@gitlab_available_projects}
| 352 | 360 | <% end %> |
| 353 | 361 | </h3> |
| 354 | 362 | |
| 355 | − <%= if @view_mode == :detail do %> | |
| 363 | + <%= cond do %> | |
| 364 | + <% @view_mode == :detail -> %> | |
| 356 | 365 | <div class="px-6"> |
| 357 | − <.query_geospatial_map | |
| 366 | + <.query_geospatial_map | |
| 358 | 367 | queries={filter_queries_by_tint(@queries, @tint)} |
| 359 | 368 | vehicle_positions={@vehicle_positions} |
| 360 | 369 | free_bikes={@free_bikes} |
| 361 | 370 | stations={@stations} |
| 362 | 371 | station_statuses={@station_statuses} |
| 372 | + route_types={@route_types} | |
| 373 | + route_lines={if @show_route_lines, do: @route_lines, else: []} | |
| 374 | + route_lines_event={if @source.type == :gtfs, do: "toggle-route-lines"} | |
| 375 | + show_route_lines={@show_route_lines} | |
| 363 | 376 | source_tint={@source_tint} |
| 364 | 377 | selected_tint={@tint} |
| 365 | 378 | class="" |
@@ -368,7 +381,75 @@ gl_available_projects={@gitlab_available_projects}
| 368 | 381 | queried_station_ids={Enum.map(@queries, fn q -> q.query && (Map.get(q.query, :stop_id) || Map.get(q.query, :stop)) end)} |
| 369 | 382 | /> |
| 370 | 383 | </div> |
| 371 | − <% else %> | |
| 384 | + | |
| 385 | + <% @view_mode == :paint -> %> | |
| 386 | + <div class="px-2"> | |
| 387 | + <p class="text-sm opacity-70 mb-3"> | |
| 388 | + Pick a tint for each query. Changes save as you click. | |
| 389 | + </p> | |
| 390 | + <%= if @queries == [] do %> | |
| 391 | + <p class="text-sm opacity-60">This source has no queries yet.</p> | |
| 392 | + <% else %> | |
| 393 | + <div class="overflow-x-auto"> | |
| 394 | + <table class="table table-compact w-full"> | |
| 395 | + <tbody> | |
| 396 | + <%= for q <- @queries do %> | |
| 397 | + <% current = q.meta && q.meta.tint %> | |
| 398 | + <tr> | |
| 399 | + <td class="align-middle"> | |
| 400 | + <div class="font-semibold"><%= q.name %></div> | |
| 401 | + <%= if q.notes not in [nil, ""] do %> | |
| 402 | + <div class="text-xs opacity-60"><%= q.notes %></div> | |
| 403 | + <% end %> | |
| 404 | + </td> | |
| 405 | + <td class="align-middle"> | |
| 406 | + <div class="flex items-center gap-2 flex-wrap"> | |
| 407 | + <label class="cursor-pointer" title="No tint"> | |
| 408 | + <input | |
| 409 | + type="radio" | |
| 410 | + name={"tint-#{q.id}"} | |
| 411 | + value="" | |
| 412 | + class="sr-only" | |
| 413 | + checked={is_nil(current)} | |
| 414 | + phx-click="paint-query" | |
| 415 | + phx-value-query-id={q.id} | |
| 416 | + phx-value-tint="" | |
| 417 | + /> | |
| 418 | + <span class={"#{swatch_class(is_nil(current))} bg-base-300"}> | |
| 419 | + <i class="fa-solid fa-ban text-xs opacity-60"></i> | |
| 420 | + </span> | |
| 421 | + </label> | |
| 422 | + | |
| 423 | + <%= for tint <- @tint_opts do %> | |
| 424 | + <label class="cursor-pointer" title={tint}> | |
| 425 | + <input | |
| 426 | + type="radio" | |
| 427 | + name={"tint-#{q.id}"} | |
| 428 | + value={tint} | |
| 429 | + class="sr-only" | |
| 430 | + checked={current == tint} | |
| 431 | + phx-click="paint-query" | |
| 432 | + phx-value-query-id={q.id} | |
| 433 | + phx-value-tint={tint} | |
| 434 | + /> | |
| 435 | + <span class={"#{swatch_class(current == tint)} bg-#{tint}-500"}> | |
| 436 | + <%= if current == tint do %> | |
| 437 | + <i class="fa-solid fa-check text-xs text-white"></i> | |
| 438 | + <% end %> | |
| 439 | + </span> | |
| 440 | + </label> | |
| 441 | + <% end %> | |
| 442 | + </div> | |
| 443 | + </td> | |
| 444 | + </tr> | |
| 445 | + <% end %> | |
| 446 | + </tbody> | |
| 447 | + </table> | |
| 448 | + </div> | |
| 449 | + <% end %> | |
| 450 | + </div> | |
| 451 | + | |
| 452 | + <% true -> %> | |
| 372 | 453 | <div class="flex flex-wrap gap-2"> |
| 373 | 454 | <%= for q <- filter_queries_by_tint(@queries, @tint) do %> |
| 374 | 455 | <%= live_patch to: Routes.query_show_path(@socket, :show, q.id), class: "btn btn-primary float-left" do %> |
@@ -405,23 +486,32 @@ gl_available_projects={@gitlab_available_projects}
| 405 | 486 | Tester |
| 406 | 487 | <span class="" phx-click="add-tester"><i class="fa-solid fa-close"></i></span> |
| 407 | 488 | </h3> |
| 408 | − <p> | |
| 489 | + <%!-- A div, not a p: everything below is block content, and the | |
| 490 | + parser silently closed the paragraph at the first div. --%> | |
| 491 | + <div> | |
| 409 | 492 | <%= case @source.type do %> |
| 410 | 493 | <% :gtfs -> %> |
| 411 | − <div class="flex justify-between"> | |
| 412 | − <div class="flex flex-col"> | |
| 494 | + <%!-- Same responsive split as the gbfs tester below, so the two | |
| 495 | + halves line up instead of the search column sizing to its | |
| 496 | + content. min-w-0 lets flex items shrink past their content. --%> | |
| 497 | + <div class="flex flex-col xl:flex-row justify-between gap-4"> | |
| 498 | + <div class="flex flex-col gap-2 w-full xl:w-1/2 min-w-0"> | |
| 413 | 499 | <div class=""> |
| 414 | − <form phx-change="do-gtfs-search" phx-debounce="100"> | |
| 415 | − <fieldset> | |
| 416 | − <label for="name">Term</label> | |
| 417 | − <input type="hidden" value={@source_id} name="id"/> | |
| 418 | − <input type="term" name="name" value={@tester_query} | |
| 419 | − placeholder="Main Street" | |
| 420 | − autofocus autocomplete="off" | |
| 421 | − class="input input-bordered input-info w-full max-w-xs" | |
| 422 | − /> | |
| 423 | − </fieldset> | |
| 424 | − </form> | |
| 500 | + <div class="flex flex-col w-full border-opacity-50"> | |
| 501 | + <div class="grid h-20 card bg-base-300 rounded-box place-items-center"> | |
| 502 | + <form phx-change="do-gtfs-search" phx-debounce="100"> | |
| 503 | + <fieldset> | |
| 504 | + <label for="name">Term</label> | |
| 505 | + <input type="hidden" value={@source_id} name="id"/> | |
| 506 | + <input type="term" name="name" value={@tester_query} | |
| 507 | + placeholder="Main Street" | |
| 508 | + autofocus autocomplete="off" | |
| 509 | + class="input input-bordered input-info w-full max-w-xs" | |
| 510 | + /> | |
| 511 | + </fieldset> | |
| 512 | + </form> | |
| 513 | + </div> | |
| 514 | + </div> | |
| 425 | 515 | </div> |
| 426 | 516 | <div class=""> |
| 427 | 517 | <ul class="menu bg-secondary rounded"> |
@@ -441,21 +531,64 @@ gl_available_projects={@gitlab_available_projects}
| 441 | 531 | </div> |
| 442 | 532 | </div> |
| 443 | 533 | <div class="flex flex-col gap-2 w-full xl:w-1/2 min-w-0"> |
| 444 | − <div class="text-xl">Selected: <%= @tester_selected_name %></div> | |
| 445 | − <div class="text-xs">id: <%= @tester_selected %></div> | |
| 446 | − <div class=""> | |
| 447 | − <%= if @tester_selected_data != %{} and @tester_selected != nil do %> | |
| 448 | − <.p_gtfs entries={condense({@source_id, :gtfs}, @tester_selected_data) |> | |
| 449 | − preview({@source_id, :gtfs})} /> | |
| 450 | − <%= if @tester_selected_data != %{} do %> | |
| 451 | − <span phx-click="add-query"><i class="fa fa-plus fa-fw"></i>query</span> | |
| 452 | − <% end %> | |
| 453 | − <% else %> | |
| 454 | − -waiting on data- | |
| 455 | − <% end %> | |
| 534 | + <div class="card bg-base-300 rounded-box min-w-0"> | |
| 535 | + <div class="card-body min-w-0"> | |
| 536 | + <div class="flex items-baseline justify-between gap-2 min-w-0"> | |
| 537 | + <h4 class="text-xl truncate"> | |
| 538 | + <%= @tester_selected_name || "Nothing selected" %> | |
| 539 | + </h4> | |
| 540 | + <span :if={@tester_selected} class="badge badge-accent shrink-0"> | |
| 541 | + <%= @tester_selected %> | |
| 542 | + </span> | |
| 543 | + </div> | |
| 544 | + | |
| 545 | + <%= if @tester_selected_data != %{} and @tester_selected != nil do %> | |
| 546 | + <.p_gtfs entries={condense({@source_id, :gtfs}, @tester_selected_data) |> | |
| 547 | + preview({@source_id, :gtfs})} /> | |
| 548 | + <button class="btn btn-secondary btn-sm mt-2 self-start" phx-click="add-query"> | |
| 549 | + <i class="fa fa-plus fa-fw mr-1"></i>query | |
| 550 | + </button> | |
| 551 | + <% else %> | |
| 552 | + <div class="grid place-items-center py-10 opacity-60"> | |
| 553 | + <i class="fa-solid fa-hourglass-half fa-lg mb-2"></i> | |
| 554 | + <span class="text-sm"> | |
| 555 | + <%= if @tester_selected, | |
| 556 | + do: "Waiting on data…", | |
| 557 | + else: "Search, then pick a stop to preview it" %> | |
| 558 | + </span> | |
| 559 | + </div> | |
| 560 | + <% end %> | |
| 561 | + </div> | |
| 456 | 562 | </div> |
| 457 | 563 | </div> |
| 458 | 564 | </div> |
| 565 | + | |
| 566 | + <% focus_stop = tester_focus_station(@stations, @tester_selected) %> | |
| 567 | + <% focus_at = focus_stop && map_coords(focus_stop) %> | |
| 568 | + <%= if focus_at do %> | |
| 569 | + <% {focus_lat, focus_lon} = focus_at %> | |
| 570 | + <div class="mt-4"> | |
| 571 | + <.query_geospatial_map | |
| 572 | + id="tester-map" | |
| 573 | + queries={[]} | |
| 574 | + stations={within_area(@stations, focus_at)} | |
| 575 | + vehicle_positions={within_area(@vehicle_positions, focus_at)} | |
| 576 | + free_bikes={within_area(@free_bikes, focus_at)} | |
| 577 | + station_statuses={@station_statuses} | |
| 578 | + route_types={@route_types} | |
| 579 | + route_lines={if @show_route_lines, do: lines_for_stop(@route_lines, @tester_route_ids), else: []} | |
| 580 | + route_lines_event={if @source.type == :gtfs, do: "toggle-route-lines"} | |
| 581 | + show_route_lines={@show_route_lines} | |
| 582 | + source_tint={@source_tint} | |
| 583 | + queried_station_ids={Enum.map(@queries, fn q -> q.query && (Map.get(q.query, :stop_id) || Map.get(q.query, :stop)) end)} | |
| 584 | + focus={%{lat: focus_lat, lng: focus_lon}} | |
| 585 | + focus_zoom={16} | |
| 586 | + height="360px" | |
| 587 | + legend={false} | |
| 588 | + /> | |
| 589 | + </div> | |
| 590 | + <% end %> | |
| 591 | + | |
| 459 | 592 | <% :gbfs -> %> |
| 460 | 593 | <%!-- min-w-0 so these can shrink; flex items default to min-width:auto |
| 461 | 594 | and otherwise refuse to go below their content width. --%> |
@@ -495,23 +628,70 @@ gl_available_projects={@gitlab_available_projects}
| 495 | 628 | </ul> |
| 496 | 629 | </div> |
| 497 | 630 | </div> |
| 498 | − <div class="flex flex-col gap-2"> | |
| 499 | − <div class="text-xl">Selected: <%= @tester_selected_name %></div> | |
| 500 | − <div class="text-xs">id: <%= @tester_selected %></div> | |
| 501 | − <div class=""> | |
| 502 | − <%= if @tester_selected_data != %{} and @tester_selected != nil do %> | |
| 503 | − <.p_gbfs entries={condense({@source_id, :gbfs}, @tester_selected_data) |> | |
| 504 | − preview({@source_id, :gbfs})} /> | |
| 505 | − <span phx-click="add-query"><i class="fa fa-plus fa-fw"></i>query</span> | |
| 506 | − <% else %> | |
| 507 | − -waiting on data- | |
| 508 | − <% end %> | |
| 631 | + <%!-- Matching the search column's width so the two halves | |
| 632 | + balance instead of the preview collapsing to content. --%> | |
| 633 | + <div class="flex flex-col gap-2 w-full xl:w-1/2 min-w-0"> | |
| 634 | + <div class="card bg-base-300 rounded-box min-w-0"> | |
| 635 | + <div class="card-body min-w-0"> | |
| 636 | + <div class="flex items-baseline justify-between gap-2 min-w-0"> | |
| 637 | + <h4 class="text-xl truncate"> | |
| 638 | + <%= @tester_selected_name || "Nothing selected" %> | |
| 639 | + </h4> | |
| 640 | + <span :if={@tester_selected} class="badge badge-accent shrink-0"> | |
| 641 | + <%= @tester_selected %> | |
| 642 | + </span> | |
| 643 | + </div> | |
| 644 | + | |
| 645 | + <%= if @tester_selected_data != %{} and @tester_selected != nil do %> | |
| 646 | + <.p_gbfs entries={condense({@source_id, :gbfs}, @tester_selected_data) |> | |
| 647 | + preview({@source_id, :gbfs})} /> | |
| 648 | + <button class="btn btn-secondary btn-sm mt-2 self-start" phx-click="add-query"> | |
| 649 | + <i class="fa fa-plus fa-fw mr-1"></i>query | |
| 650 | + </button> | |
| 651 | + <% else %> | |
| 652 | + <div class="grid place-items-center py-10 opacity-60"> | |
| 653 | + <i class="fa-solid fa-hourglass-half fa-lg mb-2"></i> | |
| 654 | + <span class="text-sm"> | |
| 655 | + <%= if @tester_selected, | |
| 656 | + do: "Waiting on data…", | |
| 657 | + else: "Search, then pick a station to preview it" %> | |
| 658 | + </span> | |
| 659 | + </div> | |
| 660 | + <% end %> | |
| 661 | + </div> | |
| 509 | 662 | </div> |
| 510 | 663 | </div> |
| 511 | 664 | </div> |
| 665 | + | |
| 666 | + <% focus_station = tester_focus_station(@stations, @tester_selected) %> | |
| 667 | + <% focus_point = focus_station && map_coords(focus_station) %> | |
| 668 | + <%= if focus_point do %> | |
| 669 | + <% {station_lat, station_lon} = focus_point %> | |
| 670 | + <div class="mt-4"> | |
| 671 | + <.query_geospatial_map | |
| 672 | + id="tester-map" | |
| 673 | + queries={[]} | |
| 674 | + stations={within_area(@stations, focus_point)} | |
| 675 | + vehicle_positions={within_area(@vehicle_positions, focus_point)} | |
| 676 | + free_bikes={within_area(@free_bikes, focus_point)} | |
| 677 | + station_statuses={@station_statuses} | |
| 678 | + route_types={@route_types} | |
| 679 | + route_lines={if @show_route_lines, do: lines_for_stop(@route_lines, @tester_route_ids), else: []} | |
| 680 | + route_lines_event={if @source.type == :gtfs, do: "toggle-route-lines"} | |
| 681 | + show_route_lines={@show_route_lines} | |
| 682 | + source_tint={@source_tint} | |
| 683 | + queried_station_ids={Enum.map(@queries, fn q -> q.query && (Map.get(q.query, :stop_id) || Map.get(q.query, :stop)) end)} | |
| 684 | + focus={%{lat: station_lat, lng: station_lon}} | |
| 685 | + focus_zoom={16} | |
| 686 | + height="360px" | |
| 687 | + legend={false} | |
| 688 | + /> | |
| 689 | + </div> | |
| 690 | + <% end %> | |
| 691 | + | |
| 512 | 692 | <% _default -> %> |
| 513 | 693 | <% end %> |
| 514 | − </p> | |
| 694 | + </div> | |
| 515 | 695 | </div> |
| 516 | 696 | </div> |
| 517 | 697 | </div> |
added
apps/room_sanctum/test/room_sanctum_web/live/source_live_bulk_paint_test.exs
+233
−0
@@ -0,0 +1,233 @@
| 1 | +defmodule RoomSanctumWeb.SourceLiveBulkPaintTest do | |
| 2 | + @moduledoc """ | |
| 3 | + The bulk paint view on an offering: one row per query, a radio per tint, | |
| 4 | + written through to the database as each radio is clicked. | |
| 5 | + | |
| 6 | + Builds its own source and queries rather than using ConfigurationFixtures, | |
| 7 | + whose source_fixture/query_fixture predate the required user_id. | |
| 8 | + """ | |
| 9 | + use RoomSanctumWeb.ConnCase | |
| 10 | + | |
| 11 | + import Phoenix.LiveViewTest | |
| 12 | + | |
| 13 | + alias RoomSanctum.{Accounts, Configuration} | |
| 14 | + | |
| 15 | + # ConnCase.register_and_log_in_user/1 reaches for RoomSanctum.AccountsFixtures, | |
| 16 | + # which does not exist in this tree, so the user is registered here instead. | |
| 17 | + setup %{conn: conn} do | |
| 18 | + {:ok, user} = | |
| 19 | + Accounts.register_user(%{ | |
| 20 | + email: "painter#{System.unique_integer([:positive])}@example.com", | |
| 21 | + password: "hello world!hello world!" | |
| 22 | + }) | |
| 23 | + | |
| 24 | + %{conn: log_in_user(conn, user), user: user} | |
| 25 | + end | |
| 26 | + | |
| 27 | + setup %{user: user} do | |
| 28 | + # :bourse has an empty source config and a one-field query, so the | |
| 29 | + # fixtures stay about painting rather than about query shapes. | |
| 30 | + {:ok, source} = | |
| 31 | + Configuration.create_source(%{ | |
| 32 | + name: "Markets", | |
| 33 | + notes: "", | |
| 34 | + type: :bourse, | |
| 35 | + enabled: true, | |
| 36 | + user_id: user.id, | |
| 37 | + config: %{"__type__" => "bourse"} | |
| 38 | + }) | |
| 39 | + | |
| 40 | + queries = | |
| 41 | + for symbol <- ~w(AAPL MSFT GOOG) do | |
| 42 | + {:ok, query} = | |
| 43 | + Configuration.create_query(%{ | |
| 44 | + name: symbol, | |
| 45 | + notes: "note for #{symbol}", | |
| 46 | + source_id: source.id, | |
| 47 | + user_id: user.id, | |
| 48 | + query: %{"__type__" => "bourse", "symbol" => symbol} | |
| 49 | + }) | |
| 50 | + | |
| 51 | + query | |
| 52 | + end | |
| 53 | + | |
| 54 | + %{source: source, queries: queries} | |
| 55 | + end | |
| 56 | + | |
| 57 | + defp tint_of(query_id) do | |
| 58 | + query = Configuration.get_query!(query_id) | |
| 59 | + query.meta && query.meta.tint | |
| 60 | + end | |
| 61 | + | |
| 62 | + describe "entering the view" do | |
| 63 | + test "the button is offered alongside the map view toggle", %{conn: conn, source: source} do | |
| 64 | + {:ok, _live, html} = live(conn, Routes.source_show_path(conn, :show, source)) | |
| 65 | + | |
| 66 | + assert html =~ "Bulk Paint" | |
| 67 | + assert html =~ "Map View" | |
| 68 | + end | |
| 69 | + | |
| 70 | + test "clicking it lists every query for the source", %{ | |
| 71 | + conn: conn, | |
| 72 | + source: source, | |
| 73 | + queries: queries | |
| 74 | + } do | |
| 75 | + {:ok, live, _html} = live(conn, Routes.source_show_path(conn, :show, source)) | |
| 76 | + | |
| 77 | + html = live |> element("button", "Bulk Paint") |> render_click() | |
| 78 | + | |
| 79 | + for query <- queries do | |
| 80 | + assert html =~ query.name | |
| 81 | + end | |
| 82 | + end | |
| 83 | + | |
| 84 | + test "each query gets its own radio group, one radio per tint plus none", %{ | |
| 85 | + conn: conn, | |
| 86 | + source: source, | |
| 87 | + queries: queries | |
| 88 | + } do | |
| 89 | + {:ok, live, _html} = live(conn, Routes.source_show_path(conn, :show, source)) | |
| 90 | + live |> element("button", "Bulk Paint") |> render_click() | |
| 91 | + | |
| 92 | + html = render(live) | |
| 93 | + | |
| 94 | + for query <- queries do | |
| 95 | + radios = Regex.scan(~r/name="tint-#{query.id}"/, html) |> length() | |
| 96 | + | |
| 97 | + # nine tints in the palette, plus the "no tint" option | |
| 98 | + assert radios == 10, "query #{query.id} rendered #{radios} radios" | |
| 99 | + end | |
| 100 | + end | |
| 101 | + | |
| 102 | + test "the toggle is a toggle -- clicking twice returns to the query list", %{ | |
| 103 | + conn: conn, | |
| 104 | + source: source | |
| 105 | + } do | |
| 106 | + {:ok, live, _html} = live(conn, Routes.source_show_path(conn, :show, source)) | |
| 107 | + | |
| 108 | + assert live |> element("button", "Bulk Paint") |> render_click() =~ "Changes save as you click" | |
| 109 | + refute live |> element("button", "Bulk Paint") |> render_click() =~ "Changes save as you click" | |
| 110 | + end | |
| 111 | + end | |
| 112 | + | |
| 113 | + describe "painting" do | |
| 114 | + test "clicking a tint persists it to that query only", %{ | |
| 115 | + conn: conn, | |
| 116 | + source: source, | |
| 117 | + queries: [first, second, third] | |
| 118 | + } do | |
| 119 | + {:ok, live, _html} = live(conn, Routes.source_show_path(conn, :show, source)) | |
| 120 | + live |> element("button", "Bulk Paint") |> render_click() | |
| 121 | + | |
| 122 | + live | |
| 123 | + |> element(~s{input[name="tint-#{first.id}"][value="sky"]}) | |
| 124 | + |> render_click() | |
| 125 | + | |
| 126 | + assert tint_of(first.id) == "sky" | |
| 127 | + assert tint_of(second.id) == nil | |
| 128 | + assert tint_of(third.id) == nil | |
| 129 | + end | |
| 130 | + | |
| 131 | + test "a whole pass over the list is retained", %{ | |
| 132 | + conn: conn, | |
| 133 | + source: source, | |
| 134 | + queries: [a, b, c] | |
| 135 | + } do | |
| 136 | + {:ok, live, _html} = live(conn, Routes.source_show_path(conn, :show, source)) | |
| 137 | + live |> element("button", "Bulk Paint") |> render_click() | |
| 138 | + | |
| 139 | + for {query, tint} <- [{a, "amber"}, {b, "lime"}, {c, "fuchsia"}] do | |
| 140 | + live | |
| 141 | + |> element(~s{input[name="tint-#{query.id}"][value="#{tint}"]}) | |
| 142 | + |> render_click() | |
| 143 | + end | |
| 144 | + | |
| 145 | + assert tint_of(a.id) == "amber" | |
| 146 | + assert tint_of(b.id) == "lime" | |
| 147 | + assert tint_of(c.id) == "fuchsia" | |
| 148 | + end | |
| 149 | + | |
| 150 | + test "repainting replaces rather than stacks", %{conn: conn, source: source, queries: [q | _]} do | |
| 151 | + {:ok, live, _html} = live(conn, Routes.source_show_path(conn, :show, source)) | |
| 152 | + live |> element("button", "Bulk Paint") |> render_click() | |
| 153 | + | |
| 154 | + live |> element(~s{input[name="tint-#{q.id}"][value="rose"]}) |> render_click() | |
| 155 | + assert tint_of(q.id) == "rose" | |
| 156 | + | |
| 157 | + live |> element(~s{input[name="tint-#{q.id}"][value="violet"]}) |> render_click() | |
| 158 | + assert tint_of(q.id) == "violet" | |
| 159 | + end | |
| 160 | + | |
| 161 | + test "the none radio clears a tint", %{conn: conn, source: source, queries: [q | _]} do | |
| 162 | + {:ok, live, _html} = live(conn, Routes.source_show_path(conn, :show, source)) | |
| 163 | + live |> element("button", "Bulk Paint") |> render_click() | |
| 164 | + | |
| 165 | + live |> element(~s{input[name="tint-#{q.id}"][value="emerald"]}) |> render_click() | |
| 166 | + assert tint_of(q.id) == "emerald" | |
| 167 | + | |
| 168 | + live |> element(~s{input[name="tint-#{q.id}"][value=""]}) |> render_click() | |
| 169 | + assert tint_of(q.id) == nil | |
| 170 | + end | |
| 171 | + | |
| 172 | + test "the chosen radio comes back checked", %{conn: conn, source: source, queries: [q | _]} do | |
| 173 | + {:ok, live, _html} = live(conn, Routes.source_show_path(conn, :show, source)) | |
| 174 | + live |> element("button", "Bulk Paint") |> render_click() | |
| 175 | + | |
| 176 | + html = live |> element(~s{input[name="tint-#{q.id}"][value="slate"]}) |> render_click() | |
| 177 | + | |
| 178 | + assert html =~ ~r/name="tint-#{q.id}"[^>]*value="slate"[^>]*checked/ or | |
| 179 | + html =~ ~r/value="slate"[^>]*name="tint-#{q.id}"[^>]*checked/ or | |
| 180 | + live | |
| 181 | + |> element(~s{input[name="tint-#{q.id}"][value="slate"][checked]}) | |
| 182 | + |> has_element?() | |
| 183 | + end | |
| 184 | + | |
| 185 | + test "the painted query keeps its own query embed intact", %{ | |
| 186 | + conn: conn, | |
| 187 | + source: source, | |
| 188 | + queries: [q | _] | |
| 189 | + } do | |
| 190 | + {:ok, live, _html} = live(conn, Routes.source_show_path(conn, :show, source)) | |
| 191 | + live |> element("button", "Bulk Paint") |> render_click() | |
| 192 | + | |
| 193 | + live |> element(~s{input[name="tint-#{q.id}"][value="stone"]}) |> render_click() | |
| 194 | + | |
| 195 | + reloaded = Configuration.get_query!(q.id) | |
| 196 | + assert reloaded.query.symbol == "AAPL" | |
| 197 | + assert reloaded.name == "AAPL" | |
| 198 | + end | |
| 199 | + | |
| 200 | + test "a painted tint becomes available as a filter", %{ | |
| 201 | + conn: conn, | |
| 202 | + source: source, | |
| 203 | + queries: [q | _] | |
| 204 | + } do | |
| 205 | + {:ok, live, _html} = live(conn, Routes.source_show_path(conn, :show, source)) | |
| 206 | + live |> element("button", "Bulk Paint") |> render_click() | |
| 207 | + live |> element(~s{input[name="tint-#{q.id}"][value="sky"]}) |> render_click() | |
| 208 | + | |
| 209 | + # back to the query list, where available tints render as filter dots | |
| 210 | + html = live |> element("button", "Bulk Paint") |> render_click() | |
| 211 | + assert html =~ "text-sky-500" | |
| 212 | + end | |
| 213 | + end | |
| 214 | + | |
| 215 | + describe "empty source" do | |
| 216 | + test "says so rather than rendering an empty table", %{conn: conn, user: user} do | |
| 217 | + {:ok, empty} = | |
| 218 | + Configuration.create_source(%{ | |
| 219 | + name: "No queries", | |
| 220 | + notes: "", | |
| 221 | + type: :bourse, | |
| 222 | + enabled: true, | |
| 223 | + user_id: user.id, | |
| 224 | + config: %{"__type__" => "bourse"} | |
| 225 | + }) | |
| 226 | + | |
| 227 | + {:ok, live, _html} = live(conn, Routes.source_show_path(conn, :show, empty)) | |
| 228 | + html = live |> element("button", "Bulk Paint") |> render_click() | |
| 229 | + | |
| 230 | + assert html =~ "no queries yet" | |
| 231 | + end | |
| 232 | + end | |
| 233 | +end |
Parents: 00ec7ed