Add bulk paint, and put a map and a route-lines toggle in the tester

568e93a · Gabriel Morell · 2026-08-10 22:11

17 files +677 -49
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