Add a foci query from the offering, under the + in the Queries card

26943c7 · Gabriel Morell · 2026-08-12 14:12

12 files +308 -0
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