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.
Let a query start a new vision
8665053 · Gabriel Morell · 2026-08-11 17:52
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