Let a query start a new vision

8665053 · Gabriel Morell · 2026-08-11 17:52

12 files +164 -36
Message
{commit_body(@commit)}

Files changed

modified apps/room_sanctum/lib/room_sanctum_web/live/cfg_query_live/show.ex
+56 −26
@@ -238,42 +238,72 @@ defmodule RoomSanctumWeb.QueryLive.Show do
238 238
239 239 def handle_event("add-to", %{"vision" => vision}, socket) do
240 240 Process.send_after(self(), :update_sec, 200)
241 − vis = Configuration.get_vision!(vision)
242 − query_id = String.to_integer(socket.assigns.query_id)
243 − existing_ids = vis.query_ids || []
244 −
245 − cond do
246 − query_id in existing_ids ->
247 − {:noreply, socket |> assign(:avail_sel, !socket.assigns.avail_sel)}
248 241
249 − true ->
250 − new_query = %{
251 − id: nil,
252 − data: %{order: 0, query: socket.assigns.query_id, "__type__": "pinned"},
253 − type: "pinned"
254 − }
242 + vision
243 + |> Configuration.get_vision!()
244 + |> pin_query(socket)
245 + end
255 246
256 − queries = (vis.queries |> Poison.encode!() |> Poison.decode!()) ++ [new_query]
247 + # Create a vision around this query, for when the one you want does not exist
248 + # yet -- otherwise pinning the first query to a new vision means leaving the
249 + # page, making it, and coming back.
250 + def handle_event("add-to-new", %{"vision" => %{"name" => name}}, socket) do
251 + Process.send_after(self(), :update_sec, 200)
257 252
258 − # `||` binds looser than `++`, so `vis.query_ids || [] ++ [id]` read as
259 − # `query_ids || ([] ++ [id])` and handed back the existing list
260 − # untouched. The embed grew, the ids did not, and the second query
261 − # added to any vision silently went nowhere.
262 − query_ids = existing_ids ++ [query_id]
253 + case String.trim(name) do
254 + "" ->
255 + {:noreply, put_flash(socket, :error, "Give the vision a name")}
263 256
264 − case Configuration.update_vision_ni(vis, %{queries: queries, query_ids: query_ids}) do
265 − {:ok, _vision} ->
266 − {:noreply, socket |> assign(:avail_sel, !socket.assigns.avail_sel)}
257 + name ->
258 + case Configuration.create_vision(%{
259 + name: name,
260 + user_id: socket.assigns.current_user.id
261 + }) do
262 + {:ok, vision} ->
263 + pin_query(vision, socket)
267 264
268 265 {:error, _changeset} ->
269 − {:noreply,
270 − socket
271 − |> put_flash(:error, "Could not add this to #{vis.name}")
272 − |> assign(:avail_sel, !socket.assigns.avail_sel)}
266 + {:noreply, put_flash(socket, :error, "Could not create #{name}")}
273 267 end
274 268 end
275 269 end
276 270
271 + # A vision records its queries twice: an embedded list that renders, and an
272 + # id array every "is this query in a vision" lookup reads. Both have to move.
273 + defp pin_query(vision, socket) do
274 + query_id = String.to_integer(socket.assigns.query_id)
275 + existing_ids = vision.query_ids || []
276 +
277 + if query_id in existing_ids do
278 + {:noreply, socket |> assign(:avail_sel, !socket.assigns.avail_sel)}
279 + else
280 + new_query = %{
281 + id: nil,
282 + data: %{order: 0, query: socket.assigns.query_id, "__type__": "pinned"},
283 + type: "pinned"
284 + }
285 +
286 + queries = (vision.queries |> Poison.encode!() |> Poison.decode!()) ++ [new_query]
287 +
288 + # `||` binds looser than `++`, so `vision.query_ids || [] ++ [id]` read as
289 + # `query_ids || ([] ++ [id])` and handed back the existing list untouched.
290 + # The embed grew, the ids did not, and the second query added to any
291 + # vision silently went nowhere.
292 + query_ids = existing_ids ++ [query_id]
293 +
294 + case Configuration.update_vision_ni(vision, %{queries: queries, query_ids: query_ids}) do
295 + {:ok, _vision} ->
296 + {:noreply, socket |> assign(:avail_sel, !socket.assigns.avail_sel)}
297 +
298 + {:error, _changeset} ->
299 + {:noreply,
300 + socket
301 + |> put_flash(:error, "Could not add this to #{vision.name}")
302 + |> assign(:avail_sel, !socket.assigns.avail_sel)}
303 + end
304 + end
305 + end
306 +
277 307 def handle_event("toggle-preview-mode", _params, socket) do
278 308 {:noreply, socket |> assign(:preview_mode, do_toggle(socket.assigns.preview_mode))}
279 309 end
modified apps/room_sanctum/lib/room_sanctum_web/live/cfg_query_live/show.html.heex
+27 −10
@@ -142,13 +142,12 @@
142 142 <div class="card-body">
143 143 <h3 class="card-title flex justify-between">
144 144 Visions
145 − <%= if @avail_visions != [] do %>
146 − <%= if @avail_sel do %>
147 − <span class="" phx-click="toggle-sel"><i class="fa-solid fa-minus"></i></span>
148 − <% else %>
149 − <span class="" phx-click="toggle-sel"><i class="fa-solid fa-plus"></i></span>
150 − <% end %>
151 −
145 + <%!-- Shown even with nothing to add to: that is when you want to
146 + make a vision, not when you are locked out of the panel. --%>
147 + <%= if @avail_sel do %>
148 + <span class="cursor-pointer" phx-click="toggle-sel"><i class="fa-solid fa-minus"></i></span>
149 + <% else %>
150 + <span class="cursor-pointer" phx-click="toggle-sel"><i class="fa-solid fa-plus"></i></span>
152 151 <% end %>
153 152 </h3>
154 153 <%= for v <- @visions do %>
@@ -157,10 +156,28 @@
157 156 <% end %>
158 157 <% end%>
159 158 <%= if @avail_sel do %>
160 − <h4 class="text-xl text-accent font-bold">Add to</h4>
161 − <%= for v <- @avail_visions do %>
162 − <div class="btn btn-secondary gap-2 float-left mx-2" phx-click="add-to" phx-value-vision={v.id}><%= v.name %></div>
159 + <%= if @avail_visions != [] do %>
160 + <h4 class="text-xl text-accent font-bold">Add to</h4>
161 + <div class="flex flex-wrap gap-2">
162 + <%= for v <- @avail_visions do %>
163 + <div class="btn btn-secondary gap-2" phx-click="add-to" phx-value-vision={v.id}><%= v.name %></div>
164 + <% end %>
165 + </div>
163 166 <% end %>
167 +
168 + <h4 class="text-xl text-accent font-bold mt-4">Or a new one</h4>
169 + <form phx-submit="add-to-new" class="flex gap-2 items-center max-w-md">
170 + <input
171 + type="text"
172 + name="vision[name]"
173 + placeholder="Morning commute"
174 + autocomplete="off"
175 + class="input input-bordered input-sm w-full"
176 + />
177 + <button type="submit" class="btn btn-accent btn-sm shrink-0">
178 + <i class="fa-solid fa-plus mr-1"></i>Create
179 + </button>
180 + </form>
164 181 <% end %>
165 182 </div>
166 183 </div>
modified apps/room_sanctum/test/room_sanctum_web/live/add_to_vision_test.exs
+81 −0
@@ -101,6 +101,87 @@ defmodule RoomSanctumWeb.AddToVisionTest do
101 101 assert length(vision.queries) == 1
102 102 end
103 103
104 + describe "into a vision that does not exist yet" do
105 + defp create_with(conn, query, name) do
106 + {:ok, live, _html} = live(conn, Routes.query_show_path(conn, :show, query))
107 + send(live.pid, :update_sec)
108 + _ = render(live)
109 + html = render_submit(live, "add-to-new", %{"vision" => %{"name" => name}})
110 + Process.sleep(50)
111 + html
112 + end
113 +
114 + test "creates the vision and pins the query to it", ctx do
115 + q = query(ctx, "AAPL")
116 +
117 + create_with(ctx.conn, q, "Morning commute")
118 +
119 + vision =
120 + Configuration.list_visions()
121 + |> Enum.find(&(&1.name == "Morning commute"))
122 +
123 + assert vision
124 + assert vision.query_ids == [q.id]
125 + assert length(vision.queries) == 1
126 + end
127 +
128 + test "the query page then shows it", ctx do
129 + q = query(ctx, "AAPL")
130 +
131 + create_with(ctx.conn, q, "Morning commute")
132 +
133 + visions = Configuration.get_visions(:query, to_string(q.id))
134 + assert Enum.map(visions, & &1.name) == ["Morning commute"]
135 + end
136 +
137 + test "the form is offered even when there is nothing to add to", ctx do
138 + q = query(ctx, "AAPL")
139 +
140 + # the only vision already holds it, so the "add to" list is empty
141 + add(ctx.conn, q, ctx.vision)
142 +
143 + {:ok, live, _html} = live(conn_for(ctx), Routes.query_show_path(ctx.conn, :show, q))
144 + send(live.pid, :update_sec)
145 + _ = render(live)
146 + html = render_click(live, "toggle-sel", %{})
147 +
148 + assert html =~ ~s(phx-submit="add-to-new")
149 + end
150 +
151 + test "a blank name is refused rather than making an unnamed vision", ctx do
152 + q = query(ctx, "AAPL")
153 + before = length(Configuration.list_visions())
154 +
155 + html = create_with(ctx.conn, q, " ")
156 +
157 + assert html =~ "Give the vision a name"
158 + assert length(Configuration.list_visions()) == before
159 + end
160 +
161 + test "the new vision belongs to the user who made it", ctx do
162 + q = query(ctx, "AAPL")
163 +
164 + create_with(ctx.conn, q, "Mine")
165 +
166 + vision = Configuration.list_visions() |> Enum.find(&(&1.name == "Mine"))
167 + assert vision.user_id == ctx.user.id
168 + end
169 +
170 + test "two new visions from the same query both hold it", ctx do
171 + q = query(ctx, "AAPL")
172 +
173 + create_with(ctx.conn, q, "One")
174 + create_with(ctx.conn, q, "Two")
175 +
176 + for name <- ["One", "Two"] do
177 + vision = Configuration.list_visions() |> Enum.find(&(&1.name == name))
178 + assert vision.query_ids == [q.id], "#{name} did not get the query"
179 + end
180 + end
181 + end
182 +
183 + defp conn_for(ctx), do: ctx.conn
184 +
104 185 test "a third query still lands", ctx do
105 186 queries = for symbol <- ~w(AAPL MSFT GOOG), do: query(ctx, symbol)
106 187

Parents: e3eb2ce