neiam
/apollos_crib
It's Apollo's Crib
public · Issues · Pulls 1 · Labels · Forks · Compare · Actions failure · Badges · Packages
⭐
Log in to mark this repository.
Add a foci query from the offering, under the + in the Queries card
26943c7 · Gabriel Morell · 2026-08-12 14:12
Message
{commit_body(@commit)}
Files changed
modified
apps/room_sanctum/lib/room_sanctum_web/live/cfg_source_live/show.ex
+86
−0
@@ -42,6 +42,9 @@ defmodule RoomSanctumWeb.SourceLive.Show do
| 42 | 42 | |> assign(:tester_route_ids, []) |
| 43 | 43 | |> assign(:aircraft, []) |
| 44 | 44 | |> assign(:alerts, []) |
| 45 | + |> assign(:focis, []) | |
| 46 | + |> assign(:foci_pick, nil) | |
| 47 | + |> assign(:foci_add_open, false) | |
| 45 | 48 | |> assign(:view_mode, :system) |
| 46 | 49 | |> assign(:tint_opts, RoomSanctum.Tints.all()) |
| 47 | 50 | |> assign(:gitlab_config_open, false) |
@@ -156,6 +159,11 @@ defmodule RoomSanctumWeb.SourceLive.Show do
| 156 | 159 | # would announce as "no queries yet" before correcting itself. |
| 157 | 160 | queries = Configuration.get_queries(:source, source_id) |
| 158 | 161 | |
| 162 | + focis = | |
| 163 | + if foci_source?(source.type), | |
| 164 | + do: Configuration.list_focis({:user, socket.assigns.current_user.id}), | |
| 165 | + else: [] | |
| 166 | + | |
| 159 | 167 | { |
| 160 | 168 | :noreply, |
| 161 | 169 | socket |
@@ -164,9 +172,87 @@ defmodule RoomSanctumWeb.SourceLive.Show do
| 164 | 172 | |> assign(:source_id, source_id) |
| 165 | 173 | |> assign(:queries, queries) |
| 166 | 174 | |> assign(:available_tints, get_available_tints(queries)) |
| 175 | + |> assign(:focis, focis) | |
| 167 | 176 | } |
| 168 | 177 | end |
| 169 | 178 | |
| 179 | + # Source types whose query is "this thing, at this place". Cronos is left out | |
| 180 | + # on purpose: its foci only supplies a timezone, and a cronos query still | |
| 181 | + # needs a period or a target date to mean anything, so one built from a foci | |
| 182 | + # alone would be a query that reports nothing. | |
| 183 | + @foci_sources [:weather, :ephem, :pollen, :aqi, :calendar, :icarus] | |
| 184 | + | |
| 185 | + def foci_source?(type), do: type in @foci_sources | |
| 186 | + | |
| 187 | + # Days and limit are display bounds rather than choices about what is being | |
| 188 | + # asked, so a calendar query can be created with sensible ones and adjusted | |
| 189 | + # later. Icarus defaults to an area query, which is what a foci means there. | |
| 190 | + @doc false | |
| 191 | + # Exposed for tests: an icarus query cannot be created from room_sanctum's | |
| 192 | + # test env, so this is how its shape is checked. | |
| 193 | + def foci_query_preview(type, foci_id), do: foci_query_for(type, foci_id) | |
| 194 | + | |
| 195 | + defp foci_query_for(:calendar, foci_id), | |
| 196 | + do: %{"__type__" => "calendar", "foci_id" => foci_id, "days" => 7, "limit" => 10} | |
| 197 | + | |
| 198 | + defp foci_query_for(:icarus, foci_id), | |
| 199 | + do: %{"__type__" => "icarus", "foci_id" => foci_id, "mode" => "area", "dist" => 25} | |
| 200 | + | |
| 201 | + defp foci_query_for(type, foci_id), | |
| 202 | + do: %{"__type__" => to_string(type), "foci_id" => foci_id} | |
| 203 | + | |
| 204 | + @impl true | |
| 205 | + def handle_event("toggle-foci-add", _params, socket) do | |
| 206 | + {:noreply, assign(socket, :foci_add_open, !socket.assigns.foci_add_open)} | |
| 207 | + end | |
| 208 | + | |
| 209 | + def handle_event("foci-pick", %{"foci" => %{"id" => ""}}, socket) do | |
| 210 | + {:noreply, assign(socket, :foci_pick, nil)} | |
| 211 | + end | |
| 212 | + | |
| 213 | + def handle_event("foci-pick", %{"foci" => %{"id" => id}}, socket) do | |
| 214 | + {:noreply, assign(socket, :foci_pick, id)} | |
| 215 | + end | |
| 216 | + | |
| 217 | + def handle_event("foci-add", _params, socket) do | |
| 218 | + case socket.assigns.foci_pick do | |
| 219 | + nil -> | |
| 220 | + {:noreply, put_flash(socket, :error, "Pick a foci first")} | |
| 221 | + | |
| 222 | + id -> | |
| 223 | + foci_id = String.to_integer(id) | |
| 224 | + foci = Enum.find(socket.assigns.focis, &(&1.id == foci_id)) | |
| 225 | + type = socket.assigns.source.type | |
| 226 | + | |
| 227 | + case Configuration.create_query(%{ | |
| 228 | + user_id: socket.assigns.current_user.id, | |
| 229 | + source_id: socket.assigns.source.id, | |
| 230 | + name: query_name_for(type, foci), | |
| 231 | + query: foci_query_for(type, foci_id), | |
| 232 | + public: true | |
| 233 | + }) do | |
| 234 | + {:ok, _query} -> | |
| 235 | + queries = Configuration.get_queries(:source, socket.assigns.source_id) | |
| 236 | + | |
| 237 | + {:noreply, | |
| 238 | + socket | |
| 239 | + |> assign(:queries, queries) | |
| 240 | + |> assign(:available_tints, get_available_tints(queries)) | |
| 241 | + |> assign(:foci_pick, nil) | |
| 242 | + |> assign(:foci_add_open, false) | |
| 243 | + |> put_flash(:info, "Added query for #{foci && foci.name}")} | |
| 244 | + | |
| 245 | + {:error, _changeset} -> | |
| 246 | + {:noreply, put_flash(socket, :error, "Could not add that query")} | |
| 247 | + end | |
| 248 | + end | |
| 249 | + end | |
| 250 | + | |
| 251 | + # "Weather at Home" reads better in a list of queries than "Home" alone, | |
| 252 | + # which is what the map's + query produces for a stop. | |
| 253 | + defp query_name_for(type, %{name: name}), do: "#{String.capitalize(to_string(type))} at #{name}" | |
| 254 | + defp query_name_for(type, _foci), do: to_string(type) | |
| 255 | + | |
| 170 | 256 | # Alerts are only worth fetching for a source that publishes them; a GTFS |
| 171 | 257 | # source with no alert feed configured has nothing to say. |
| 172 | 258 | defp current_alerts(%{type: :gtfs, config: %{url_rt_sa: url}} = source, queries) |
modified
apps/room_sanctum/lib/room_sanctum_web/live/cfg_source_live/show.html.heex
+28
−0
@@ -356,9 +356,37 @@ gl_available_projects={@gitlab_available_projects}
| 356 | 356 | <%= case @source.type do %> |
| 357 | 357 | <% val when val in [:gtfs, :gbfs] -> %> |
| 358 | 358 | <span class="" phx-click="add-tester"><i class="fa-solid fa-plus"></i>Test</span> |
| 359 | + <% type when type in [:weather, :ephem, :pollen, :aqi, :calendar, :icarus] -> %> | |
| 360 | + <span class="cursor-pointer" phx-click="toggle-foci-add"> | |
| 361 | + <i class={"fa-solid #{if @foci_add_open, do: "fa-minus", else: "fa-plus"}"}></i>Query | |
| 362 | + </span> | |
| 359 | 363 | <% _default -> %> |
| 360 | 364 | <% end %> |
| 361 | 365 | </h3> |
| 366 | + | |
| 367 | + <%= if @foci_add_open do %> | |
| 368 | + <%!-- These queries are "this source, at this place", so a | |
| 369 | + foci is the whole thing a new one needs. --%> | |
| 370 | + <div class="flex flex-wrap items-end gap-2 mb-3"> | |
| 371 | + <form phx-change="foci-pick"> | |
| 372 | + <select name="foci[id]" class="select select-bordered select-sm"> | |
| 373 | + <option value="">Pick a place</option> | |
| 374 | + <option :for={f <- @focis} value={f.id} selected={to_string(f.id) == @foci_pick}> | |
| 375 | + <%= f.name %> | |
| 376 | + </option> | |
| 377 | + </select> | |
| 378 | + </form> | |
| 379 | + | |
| 380 | + <button class="btn btn-secondary btn-sm" phx-click="foci-add" | |
| 381 | + disabled={is_nil(@foci_pick)}> | |
| 382 | + <i class="fa-solid fa-plus mr-1"></i>Add as query | |
| 383 | + </button> | |
| 384 | + | |
| 385 | + <span :if={@focis == []} class="text-xs opacity-60"> | |
| 386 | + No foci yet -- make one first. | |
| 387 | + </span> | |
| 388 | + </div> | |
| 389 | + <% end %> | |
| 362 | 390 | |
| 363 | 391 | <%= cond do %> |
| 364 | 392 | <% @view_mode == :detail -> %> |
added
apps/room_sanctum/test/room_sanctum_web/live/foci_quick_add_test.exs
+194
−0
@@ -0,0 +1,194 @@
| 1 | +defmodule RoomSanctumWeb.FociQuickAddTest do | |
| 2 | + @moduledoc """ | |
| 3 | + Adding a "this source, at this place" query from the offering page. | |
| 4 | + | |
| 5 | + A stop-based source gets this from the map, and a ticker from the Control | |
| 6 | + box; a foci-based one had nothing, so a weather query meant going to the | |
| 7 | + query form and picking the source back out of a list. | |
| 8 | + """ | |
| 9 | + use RoomSanctumWeb.ConnCase | |
| 10 | + | |
| 11 | + import Phoenix.LiveViewTest | |
| 12 | + | |
| 13 | + alias RoomSanctum.{Accounts, Configuration} | |
| 14 | + | |
| 15 | + setup %{conn: conn} do | |
| 16 | + {:ok, user} = | |
| 17 | + Accounts.register_user(%{ | |
| 18 | + email: "foci#{System.unique_integer([:positive])}@example.com", | |
| 19 | + password: "hello world!hello world!" | |
| 20 | + }) | |
| 21 | + | |
| 22 | + {:ok, home} = | |
| 23 | + Configuration.create_foci(%{ | |
| 24 | + name: "Home", user_id: user.id, | |
| 25 | + place: %Geo.Point{coordinates: {-71.0589, 42.3601}, srid: 4326} | |
| 26 | + }) | |
| 27 | + | |
| 28 | + %{conn: log_in_user(conn, user), user: user, home: home} | |
| 29 | + end | |
| 30 | + | |
| 31 | + defp source(ctx, type, config) do | |
| 32 | + {:ok, source} = | |
| 33 | + Configuration.create_source(%{ | |
| 34 | + name: "#{type}", notes: "", type: type, enabled: true, | |
| 35 | + user_id: ctx.user.id, config: config | |
| 36 | + }) | |
| 37 | + | |
| 38 | + source | |
| 39 | + end | |
| 40 | + | |
| 41 | + defp add_from(ctx, source) do | |
| 42 | + {:ok, live, _html} = live(ctx.conn, Routes.source_show_path(ctx.conn, :show, source)) | |
| 43 | + render_click(live, "toggle-foci-add", %{}) | |
| 44 | + render_change(live, "foci-pick", %{"foci" => %{"id" => to_string(ctx.home.id)}}) | |
| 45 | + render_click(live, "foci-add", %{}) | |
| 46 | + Configuration.get_queries(:source, source.id) | |
| 47 | + end | |
| 48 | + | |
| 49 | + describe "the picker" do | |
| 50 | + test "is offered for a source whose query is a place", ctx do | |
| 51 | + src = source(ctx, :ephem, %{"__type__" => "ephem"}) | |
| 52 | + | |
| 53 | + {:ok, live, html} = live(ctx.conn, Routes.source_show_path(ctx.conn, :show, src)) | |
| 54 | + | |
| 55 | + # tucked behind the + in the Queries card, like the tester is | |
| 56 | + assert html =~ ~s(phx-click="toggle-foci-add") | |
| 57 | + refute html =~ ~s(phx-click="foci-add") | |
| 58 | + | |
| 59 | + opened = render_click(live, "toggle-foci-add", %{}) | |
| 60 | + assert opened =~ ~s(phx-click="foci-add") | |
| 61 | + assert opened =~ "Home" | |
| 62 | + end | |
| 63 | + | |
| 64 | + test "the + closes again", ctx do | |
| 65 | + src = source(ctx, :ephem, %{"__type__" => "ephem"}) | |
| 66 | + | |
| 67 | + {:ok, live, _html} = live(ctx.conn, Routes.source_show_path(ctx.conn, :show, src)) | |
| 68 | + | |
| 69 | + assert render_click(live, "toggle-foci-add", %{}) =~ ~s(phx-click="foci-add") | |
| 70 | + refute render_click(live, "toggle-foci-add", %{}) =~ ~s(phx-click="foci-add") | |
| 71 | + end | |
| 72 | + | |
| 73 | + test "adding one closes the panel", ctx do | |
| 74 | + src = source(ctx, :ephem, %{"__type__" => "ephem"}) | |
| 75 | + | |
| 76 | + {:ok, live, _html} = live(ctx.conn, Routes.source_show_path(ctx.conn, :show, src)) | |
| 77 | + render_click(live, "toggle-foci-add", %{}) | |
| 78 | + render_change(live, "foci-pick", %{"foci" => %{"id" => to_string(ctx.home.id)}}) | |
| 79 | + html = render_click(live, "foci-add", %{}) | |
| 80 | + | |
| 81 | + refute html =~ ~s(phx-click="foci-add") | |
| 82 | + end | |
| 83 | + | |
| 84 | + test "is not offered where a foci means nothing", ctx do | |
| 85 | + src = source(ctx, :bourse, %{"__type__" => "bourse"}) | |
| 86 | + | |
| 87 | + {:ok, _live, html} = live(ctx.conn, Routes.source_show_path(ctx.conn, :show, src)) | |
| 88 | + | |
| 89 | + refute html =~ ~s(phx-click="foci-add") | |
| 90 | + end | |
| 91 | + | |
| 92 | + test "says so when there are no foci to pick", ctx do | |
| 93 | + {:ok, other} = | |
| 94 | + Accounts.register_user(%{ | |
| 95 | + email: "nofoci#{System.unique_integer([:positive])}@example.com", | |
| 96 | + password: "hello world!hello world!" | |
| 97 | + }) | |
| 98 | + | |
| 99 | + {:ok, src} = | |
| 100 | + Configuration.create_source(%{ | |
| 101 | + name: "ephem", notes: "", type: :ephem, enabled: true, | |
| 102 | + user_id: other.id, config: %{"__type__" => "ephem"} | |
| 103 | + }) | |
| 104 | + | |
| 105 | + {:ok, live, _html} = | |
| 106 | + live(log_in_user(build_conn(), other), Routes.source_show_path(build_conn(), :show, src)) | |
| 107 | + | |
| 108 | + assert render_click(live, "toggle-foci-add", %{}) =~ "No foci yet" | |
| 109 | + end | |
| 110 | + end | |
| 111 | + | |
| 112 | + describe "adding" do | |
| 113 | + test "a foci alone is enough for weather", ctx do | |
| 114 | + src = source(ctx, :weather, %{"__type__" => "weather", "api_key" => "k", "units" => "metric"}) | |
| 115 | + | |
| 116 | + assert [query] = add_from(ctx, src) | |
| 117 | + assert query.query.foci_id == ctx.home.id | |
| 118 | + assert query.name == "Weather at Home" | |
| 119 | + end | |
| 120 | + | |
| 121 | + test "and for ephem, pollen and aqi", ctx do | |
| 122 | + for {type, config} <- [ | |
| 123 | + {:ephem, %{"__type__" => "ephem"}}, | |
| 124 | + {:pollen, %{"__type__" => "pollen", "api_key" => "k"}}, | |
| 125 | + {:aqi, %{"__type__" => "aqi"}} | |
| 126 | + ] do | |
| 127 | + src = source(ctx, type, config) | |
| 128 | + | |
| 129 | + assert [query] = add_from(ctx, src), "#{type} added nothing" | |
| 130 | + assert query.query.foci_id == ctx.home.id, "#{type} lost the foci" | |
| 131 | + end | |
| 132 | + end | |
| 133 | + | |
| 134 | + test "a calendar query gets workable display bounds", ctx do | |
| 135 | + src = source(ctx, :calendar, %{"__type__" => "calendar", "url" => "https://e.test/c.ics"}) | |
| 136 | + | |
| 137 | + assert [query] = add_from(ctx, src) | |
| 138 | + # days and limit are required, so a query without them could not be saved | |
| 139 | + assert query.query.days == 7 | |
| 140 | + assert query.query.limit == 10 | |
| 141 | + end | |
| 142 | + | |
| 143 | + # An icarus query cannot be created from this app's tests: its changeset | |
| 144 | + # validates against RoomIcarus.Classify, which is not loadable here. The | |
| 145 | + # shape it builds is covered by foci_query_for/2 below. | |
| 146 | + test "an icarus query is built as an area query, which is what a foci means" do | |
| 147 | + assert RoomSanctumWeb.SourceLive.Show.foci_query_preview(:icarus, 7) == | |
| 148 | + %{"__type__" => "icarus", "foci_id" => 7, "mode" => "area", "dist" => 25} | |
| 149 | + end | |
| 150 | + | |
| 151 | + test "nothing is added without picking a foci", ctx do | |
| 152 | + src = source(ctx, :ephem, %{"__type__" => "ephem"}) | |
| 153 | + | |
| 154 | + {:ok, live, _html} = live(ctx.conn, Routes.source_show_path(ctx.conn, :show, src)) | |
| 155 | + render_click(live, "toggle-foci-add", %{}) | |
| 156 | + html = render_click(live, "foci-add", %{}) | |
| 157 | + | |
| 158 | + assert html =~ "Pick a foci first" | |
| 159 | + assert Configuration.get_queries(:source, src.id) == [] | |
| 160 | + end | |
| 161 | + | |
| 162 | + test "the new query shows up in the source's list straight away", ctx do | |
| 163 | + src = source(ctx, :ephem, %{"__type__" => "ephem"}) | |
| 164 | + | |
| 165 | + {:ok, live, _html} = live(ctx.conn, Routes.source_show_path(ctx.conn, :show, src)) | |
| 166 | + render_click(live, "toggle-foci-add", %{}) | |
| 167 | + render_change(live, "foci-pick", %{"foci" => %{"id" => to_string(ctx.home.id)}}) | |
| 168 | + html = render_click(live, "foci-add", %{}) | |
| 169 | + | |
| 170 | + assert html =~ "Ephem at Home" | |
| 171 | + end | |
| 172 | + | |
| 173 | + test "two foci give two queries", ctx do | |
| 174 | + {:ok, work} = | |
| 175 | + Configuration.create_foci(%{ | |
| 176 | + name: "Work", user_id: ctx.user.id, | |
| 177 | + place: %Geo.Point{coordinates: {-71.09, 42.36}, srid: 4326} | |
| 178 | + }) | |
| 179 | + | |
| 180 | + src = source(ctx, :ephem, %{"__type__" => "ephem"}) | |
| 181 | + | |
| 182 | + {:ok, live, _html} = live(ctx.conn, Routes.source_show_path(ctx.conn, :show, src)) | |
| 183 | + | |
| 184 | + for foci <- [ctx.home, work] do | |
| 185 | + render_click(live, "toggle-foci-add", %{}) | |
| 186 | + render_change(live, "foci-pick", %{"foci" => %{"id" => to_string(foci.id)}}) | |
| 187 | + render_click(live, "foci-add", %{}) | |
| 188 | + end | |
| 189 | + | |
| 190 | + names = Configuration.get_queries(:source, src.id) |> Enum.map(& &1.name) |> Enum.sort() | |
| 191 | + assert names == ["Ephem at Home", "Ephem at Work"] | |
| 192 | + end | |
| 193 | + end | |
| 194 | +end |
Parents: 7e1a811