Paginate the repository and organization listings

6280797 · Gabriel Morell · 2026-09-11 02:03

5 files +404 -13
Message
{commit_body(@commit)}

Files changed

modified lib/git_gud_web/live/org_live/index.ex
+79 −2
@@ -6,6 +6,8 @@ defmodule GitGudWeb.OrgLive.Index do
6 6 alias GitGud.Profiles
7 7 alias GitGud.Repositories
8 8
9 + @per_page 7
10 +
9 11 # Cards show one tidy row of pins; the org page has the rest. Also
10 12 # bounds the git work — `PinnedReposGrid` resolves a top language per
11 13 # card, which is a ref lookup each.
@@ -27,6 +29,38 @@ defmodule GitGudWeb.OrgLive.Index do
27 29 |> assign(:page_title, "Organizations")}
28 30 end
29 31
32 + @impl true
33 + def handle_params(params, _uri, socket) do
34 + {:noreply, assign(socket, :page, page_param(params["page"]))}
35 + end
36 +
37 + defp page_param(nil), do: 1
38 +
39 + defp page_param(raw) do
40 + case Integer.parse(to_string(raw)) do
41 + {n, ""} when n > 0 -> n
42 + _ -> 1
43 + end
44 + end
45 +
46 + @doc false
47 + def per_page, do: @per_page
48 +
49 + defp page_count(total) when total <= 0, do: 1
50 + defp page_count(total), do: ceil(total / @per_page)
51 +
52 + # Page 1 keeps the bare /orgs.
53 + defp page_path(1), do: ~p"/orgs"
54 + defp page_path(n), do: ~p"/orgs/page/#{n}"
55 +
56 + # A window around the current page, so a long list doesn't render a
57 + # link per page.
58 + defp page_window(page, pages) do
59 + first = max(1, min(page - 2, pages - 4))
60 + last = min(pages, max(page + 2, 5))
61 + first..last//1
62 + end
63 +
30 64 defp pins_for(pins, org), do: pins |> Map.get(org.id, []) |> Enum.take(@pins_per_card)
31 65
32 66 defp count(counts, org), do: Map.get(counts, org.id, 0)
@@ -41,13 +75,27 @@ defmodule GitGudWeb.OrgLive.Index do
41 75
42 76 @impl true
43 77 def render(assigns) do
78 + total = length(assigns.orgs)
79 + pages = page_count(total)
80 + # A deep link past the end lands on the last page, not on nothing.
81 + page = min(assigns.page, pages)
82 +
83 + assigns =
84 + assigns
85 + |> assign(:page, page)
86 + |> assign(:pages, pages)
87 + |> assign(:total, total)
88 + |> assign(:visible, Enum.slice(assigns.orgs, (page - 1) * @per_page, @per_page))
89 +
44 90 ~H"""
45 91 <Layouts.app flash={@flash} current_scope={@current_scope}>
46 92 <div class="space-y-6">
47 93 <header class="flex items-baseline justify-between gap-4">
48 94 <div>
49 95 <h1 class="text-2xl font-semibold">Organizations</h1>
50 <p class="text-sm opacity-70 mt-1">Orgs you belong to.</p>
96 + <p class="text-sm opacity-70 mt-1">
97 + Orgs you belong to.<span :if={@pages > 1}> Page {@page} of {@pages}.</span>
98 + </p>
51 99 </div>
52 100 <.link navigate={~p"/orgs/new"} class="btn btn-sm btn-primary">
53 101 New organization
@@ -62,7 +110,7 @@ defmodule GitGudWeb.OrgLive.Index do
62 110
63 111 <ul class="space-y-4">
64 112 <li
65 :for={org <- @orgs}
113 + :for={org <- @visible}
66 114 id={"org-#{org.id}"}
67 115 class="rounded-lg border border-base-300 p-4 space-y-3 transition-colors hover:border-primary/50"
68 116 >
@@ -134,6 +182,35 @@ defmodule GitGudWeb.OrgLive.Index do
134 182 </p>
135 183 </li>
136 184 </ul>
185 +
186 + <nav :if={@pages > 1} class="flex items-center justify-center gap-1" aria-label="Pagination">
187 + <.link
188 + :if={@page > 1}
189 + patch={page_path(@page - 1)}
190 + class="btn btn-sm btn-ghost"
191 + rel="prev"
192 + >
193 + ← Previous
194 + </.link>
195 +
196 + <.link
197 + :for={n <- page_window(@page, @pages)}
198 + patch={page_path(n)}
199 + class={["btn btn-sm", if(n == @page, do: "btn-active", else: "btn-ghost")]}
200 + aria-current={n == @page && "page"}
201 + >
202 + {n}
203 + </.link>
204 +
205 + <.link
206 + :if={@page < @pages}
207 + patch={page_path(@page + 1)}
208 + class="btn btn-sm btn-ghost"
209 + rel="next"
210 + >
211 + Next
212 + </.link>
213 + </nav>
137 214 </div>
138 215 </Layouts.app>
139 216 """
modified lib/git_gud_web/live/repo_live/index.ex
+120 −11
@@ -9,6 +9,8 @@ defmodule GitGudWeb.RepoLive.Index do
9 9
10 10 @sorts ~w(pushed name size)
11 11
12 + @per_page 15
13 +
12 14 @impl true
13 15 def mount(_params, _session, socket) do
14 16 repos =
@@ -30,10 +32,58 @@ defmodule GitGudWeb.RepoLive.Index do
30 32 |> assign(:repos, repos)
31 33 |> assign(:languages, Repositories.top_languages_for(ids))
32 34 |> assign(:tags, GitGud.Tags.names_by_repository(ids))
33 |> assign(:ci_status, Workflows.latest_run_status_by_repo(ids))
34 |> assign(:query, "")
35 |> assign(:scope_filter, "all")
36 |> assign(:sort, "pushed")}
35 + |> assign(:ci_status, Workflows.latest_run_status_by_repo(ids))}
36 + end
37 +
38 + @impl true
39 + def handle_params(params, _uri, socket) do
40 + {:noreply,
41 + socket
42 + |> assign(:query, params["query"] || "")
43 + |> assign(:sort, sort_param(params["sort"], "pushed"))
44 + |> assign(:scope_filter, scope_param(params["scope"]))
45 + |> assign(:page, page_param(params["page"]))}
46 + end
47 +
48 + defp scope_param(scope) when scope in ~w(all mine), do: scope
49 + defp scope_param(_), do: "all"
50 +
51 + defp page_param(nil), do: 1
52 +
53 + defp page_param(raw) do
54 + case Integer.parse(to_string(raw)) do
55 + {n, ""} when n > 0 -> n
56 + _ -> 1
57 + end
58 + end
59 +
60 + # The page is a path segment — /repositories/2 — with the filter
61 + # state trailing as a query string. Only what differs from the
62 + # default rides along, so an unfiltered first page stays a bare
63 + # /repositories.
64 + defp path_for(assigns, overrides) do
65 + merged =
66 + %{
67 + "query" => assigns.query,
68 + "sort" => assigns.sort,
69 + "scope" => assigns.scope_filter,
70 + "page" => assigns.page
71 + }
72 + |> Map.merge(overrides)
73 +
74 + {page, filters} = Map.pop(merged, "page")
75 +
76 + query =
77 + filters
78 + |> Enum.reject(fn {k, v} ->
79 + v in [nil, ""] or (k == "sort" and v == "pushed") or (k == "scope" and v == "all")
80 + end)
81 + |> Map.new()
82 +
83 + case page_param(page) do
84 + 1 -> ~p"/repositories?#{query}"
85 + n -> ~p"/repositories/#{n}?#{query}"
86 + end
37 87 end
38 88
39 89 defp namespace_handle(%{organization: %{handle: h}}) when is_binary(h), do: h
@@ -49,15 +99,19 @@ defmodule GitGudWeb.RepoLive.Index do
49 99
50 100 @impl true
51 101 def handle_event("filter", params, socket) do
52 {:noreply,
53 socket
54 |> assign(:query, params["query"] || socket.assigns.query)
55 |> assign(:sort, sort_param(params["sort"], socket.assigns.sort))}
102 + overrides = %{
103 + "query" => params["query"] || socket.assigns.query,
104 + "sort" => sort_param(params["sort"], socket.assigns.sort),
105 + "page" => 1
106 + }
107 +
108 + {:noreply, push_patch(socket, to: path_for(socket.assigns, overrides))}
56 109 end
57 110
58 111 def handle_event("scope", %{"scope" => scope}, socket)
59 112 when scope in ~w(all mine) do
60 {:noreply, assign(socket, :scope_filter, scope)}
113 + overrides = %{"scope" => scope, "page" => 1}
114 + {:noreply, push_patch(socket, to: path_for(socket.assigns, overrides))}
61 115 end
62 116
63 117 defp sort_param(sort, _fallback) when sort in @sorts, do: sort
@@ -129,9 +183,27 @@ defmodule GitGudWeb.RepoLive.Index do
129 183 defp exact_time(nil), do: "no pushes yet"
130 184 defp exact_time(dt), do: Calendar.strftime(dt, "%Y-%m-%d %H:%M UTC")
131 185
186 + defp page_count(total) when total <= 0, do: 1
187 + defp page_count(total), do: ceil(total / @per_page)
188 +
189 + @doc false
190 + def per_page, do: @per_page
191 +
132 192 @impl true
133 193 def render(assigns) do
134 assigns = assign(assigns, :visible, visible(assigns))
194 + matching = visible(assigns)
195 + total = length(matching)
196 + pages = page_count(total)
197 + # A deep link to a page that no longer exists lands on the last one
198 + # rather than on nothing.
199 + page = min(assigns.page, pages)
200 +
201 + assigns =
202 + assigns
203 + |> assign(:visible, Enum.slice(matching, (page - 1) * @per_page, @per_page))
204 + |> assign(:total, total)
205 + |> assign(:pages, pages)
206 + |> assign(:page, page)
135 207
136 208 ~H"""
137 209 <Layouts.app flash={@flash} current_scope={@current_scope}>
@@ -140,7 +212,7 @@ defmodule GitGudWeb.RepoLive.Index do
140 212 <div>
141 213 <h1 class="text-2xl font-semibold">Repositories</h1>
142 214 <p class="text-sm opacity-70 mt-1">
143 {length(@visible)} of {length(@repos)} shown
215 + {@total} of {length(@repos)} shown<span :if={@pages > 1}> · page {@page} of {@pages}</span>
144 216 </p>
145 217 </div>
146 218 <div :if={signed_in?(@current_scope)} class="flex items-center gap-2 shrink-0">
@@ -276,8 +348,45 @@ defmodule GitGudWeb.RepoLive.Index do
276 348 </div>
277 349 </li>
278 350 </ul>
351 +
352 + <nav :if={@pages > 1} class="flex items-center justify-center gap-1" aria-label="Pagination">
353 + <.link
354 + :if={@page > 1}
355 + patch={path_for(assigns, %{"page" => @page - 1})}
356 + class="btn btn-sm btn-ghost"
357 + rel="prev"
358 + >
359 + ← Previous
360 + </.link>
361 +
362 + <.link
363 + :for={n <- page_window(@page, @pages)}
364 + patch={path_for(assigns, %{"page" => n})}
365 + class={["btn btn-sm", if(n == @page, do: "btn-active", else: "btn-ghost")]}
366 + aria-current={n == @page && "page"}
367 + >
368 + {n}
369 + </.link>
370 +
371 + <.link
372 + :if={@page < @pages}
373 + patch={path_for(assigns, %{"page" => @page + 1})}
374 + class="btn btn-sm btn-ghost"
375 + rel="next"
376 + >
377 + Next
378 + </.link>
379 + </nav>
279 380 </div>
280 381 </Layouts.app>
281 382 """
282 383 end
384 +
385 + # A window around the current page, so a thousand repos don't render
386 + # a thousand page links.
387 + defp page_window(page, pages) do
388 + first = max(1, min(page - 2, pages - 4))
389 + last = min(pages, max(page + 2, 5))
390 + first..last//1
391 + end
283 392 end
modified lib/git_gud_web/router.ex
+6 −0
@@ -183,6 +183,9 @@ defmodule GitGudWeb.Router do
183 183 live "/r/:owner/:name/fork", RepoLive.Fork, :new
184 184 live "/orgs", OrgLive.Index, :index
185 185 live "/orgs/new", OrgLive.New, :new
186 + # Not /orgs/2 — an org handle may start with a digit, so that
187 + # would be ambiguous with the org actually named "2".
188 + live "/orgs/page/:page", OrgLive.Index, :index
186 189 end
187 190
188 191 live_session :admin,
@@ -200,6 +203,9 @@ defmodule GitGudWeb.Router do
200 203 # LiveView itself.
201 204 live "/", PageLive.Home, :index
202 205 live "/repositories", RepoLive.Index, :index
206 + # Page 2 onwards. Declared after /repositories/new and /import,
207 + # which live in the authenticated scope above and so match first.
208 + live "/repositories/:page", RepoLive.Index, :index
203 209 live "/u/:handle", UserLive.Profile, :show
204 210 live "/syms", SymsLive.Top, :index
205 211 live "/tags", TagsLive.Index, :index
modified test/git_gud_web/live/org_live/index_test.exs
+88 −0
@@ -106,4 +106,92 @@ defmodule GitGudWeb.OrgLive.IndexTest do
106 106
107 107 assert html =~ "New organization"
108 108 end
109 +
110 + describe "pagination" do
111 + defp many_orgs(user, n) do
112 + for i <- 1..n do
113 + {:ok, org} =
114 + Organizations.create_organization(user, %{"handle" => "paged-org-#{100 + i}"})
115 +
116 + org
117 + end
118 + end
119 +
120 + test "no pager while everything fits", %{conn: conn} do
121 + user = user_fixture()
122 + _ = many_orgs(user, GitGudWeb.OrgLive.Index.per_page())
123 +
124 + {:ok, lv, _html} = live(log_in_user(conn, user), ~p"/orgs")
125 +
126 + refute has_element?(lv, "nav[aria-label=Pagination]")
127 + end
128 +
129 + test "a pager appears at one too many", %{conn: conn} do
130 + user = user_fixture()
131 + _ = many_orgs(user, GitGudWeb.OrgLive.Index.per_page() + 1)
132 +
133 + {:ok, lv, html} = live(log_in_user(conn, user), ~p"/orgs")
134 +
135 + assert has_element?(lv, "nav[aria-label=Pagination]")
136 + assert html =~ "Page 1 of 2"
137 + end
138 +
139 + test "only a page's worth renders", %{conn: conn} do
140 + user = user_fixture()
141 + per = GitGudWeb.OrgLive.Index.per_page()
142 + _ = many_orgs(user, per + 1)
143 +
144 + {:ok, lv, _html} = live(log_in_user(conn, user), ~p"/orgs")
145 +
146 + shown = lv |> render() |> String.split("id=\"org-") |> length()
147 + assert shown - 1 == per
148 + end
149 +
150 + test "a deep link to page 2 works", %{conn: conn} do
151 + user = user_fixture()
152 + _ = many_orgs(user, GitGudWeb.OrgLive.Index.per_page() + 1)
153 +
154 + {:ok, _lv, html} = live(log_in_user(conn, user), ~p"/orgs/page/2")
155 +
156 + assert html =~ "Page 2 of 2"
157 + end
158 +
159 + test "past the end lands on the last page", %{conn: conn} do
160 + user = user_fixture()
161 + _ = many_orgs(user, GitGudWeb.OrgLive.Index.per_page() + 1)
162 +
163 + {:ok, _lv, html} = live(log_in_user(conn, user), ~p"/orgs/page/99")
164 +
165 + assert html =~ "Page 2 of 2"
166 + end
167 +
168 + test "a nonsense page falls back to the first", %{conn: conn} do
169 + user = user_fixture()
170 + _ = many_orgs(user, GitGudWeb.OrgLive.Index.per_page() + 1)
171 +
172 + {:ok, _lv, html} = live(log_in_user(conn, user), ~p"/orgs/page/banana")
173 +
174 + assert html =~ "Page 1 of 2"
175 + end
176 +
177 + test "the pager links use /orgs/page/N", %{conn: conn} do
178 + user = user_fixture()
179 + _ = many_orgs(user, GitGudWeb.OrgLive.Index.per_page() + 1)
180 +
181 + {:ok, lv, _html} = live(log_in_user(conn, user), ~p"/orgs")
182 +
183 + assert has_element?(lv, ~s{nav[aria-label=Pagination] a[href="/orgs/page/2"]})
184 + end
185 +
186 + test "an org whose handle is a number is still its own page", %{conn: conn} do
187 + user = user_fixture()
188 + {:ok, numeric} = Organizations.create_organization(user, %{"handle" => "2"})
189 +
190 + # /orgs/2 is the org, not page 2 — which is why the pager uses
191 + # /orgs/page/N.
192 + {:ok, _lv, html} = live(log_in_user(conn, user), ~p"/orgs/#{numeric.handle}")
193 +
194 + assert html =~ "@2"
195 + end
196 + end
109 197 end
modified test/git_gud_web/live/repo_live/index_test.exs
+111 −0
@@ -176,4 +176,115 @@ defmodule GitGudWeb.RepoLive.IndexTest do
176 176 refute html =~ ">alpha<"
177 177 end
178 178 end
179 +
180 + describe "pagination" do
181 + defp many_repos(user, n) do
182 + for i <- 1..n do
183 + {_u, repo} = repository_fixture(%{owner: user, name: "paged-#{100 + i}"})
184 + repo
185 + end
186 + end
187 +
188 + test "no pager while everything fits on one page", %{conn: conn, user: user} do
189 + _ = many_repos(user, GitGudWeb.RepoLive.Index.per_page())
190 +
191 + {:ok, lv, _html} = live(log_in_user(conn, user), ~p"/repositories")
192 +
193 + refute has_element?(lv, "nav[aria-label=Pagination]")
194 + end
195 +
196 + test "a pager appears once there's one too many", %{conn: conn, user: user} do
197 + _ = many_repos(user, GitGudWeb.RepoLive.Index.per_page() + 1)
198 +
199 + {:ok, lv, html} = live(log_in_user(conn, user), ~p"/repositories")
200 +
201 + assert has_element?(lv, "nav[aria-label=Pagination]")
202 + assert html =~ "page 1 of 2"
203 + end
204 +
205 + test "only a page's worth renders at a time", %{conn: conn, user: user} do
206 + per = GitGudWeb.RepoLive.Index.per_page()
207 + _ = many_repos(user, per + 1)
208 +
209 + {:ok, lv, _html} = live(log_in_user(conn, user), ~p"/repositories")
210 +
211 + shown = lv |> render() |> String.split("id=\"repo-") |> length()
212 + assert shown - 1 == per
213 + end
214 +
215 + test "a deep link to page 2 shows the rest", %{conn: conn, user: user} do
216 + per = GitGudWeb.RepoLive.Index.per_page()
217 + _ = many_repos(user, per + 1)
218 +
219 + {:ok, _lv, html} = live(log_in_user(conn, user), ~p"/repositories/2")
220 +
221 + assert html =~ "page 2 of 2"
222 + end
223 +
224 + test "a deep link past the end lands on the last page", %{conn: conn, user: user} do
225 + _ = many_repos(user, GitGudWeb.RepoLive.Index.per_page() + 1)
226 +
227 + {:ok, _lv, html} = live(log_in_user(conn, user), ~p"/repositories/99")
228 +
229 + assert html =~ "page 2 of 2"
230 + end
231 +
232 + test "a nonsense page falls back to the first", %{conn: conn, user: user} do
233 + _ = many_repos(user, GitGudWeb.RepoLive.Index.per_page() + 1)
234 +
235 + {:ok, _lv, html} = live(log_in_user(conn, user), ~p"/repositories/banana")
236 +
237 + assert html =~ "page 1 of 2"
238 + end
239 +
240 + test "the filter travels in the link, so a page keeps its list", %{conn: conn, user: user} do
241 + {_u, _match} = repository_fixture(%{owner: user, name: "needle-one"})
242 + _ = many_repos(user, GitGudWeb.RepoLive.Index.per_page() + 1)
243 +
244 + {:ok, _lv, html} = live(log_in_user(conn, user), ~p"/repositories?query=needle")
245 +
246 + assert html =~ "needle-one"
247 + refute html =~ "paged-101"
248 + # One match, so no pager.
249 + refute html =~ "aria-label=\"Pagination\""
250 + end
251 +
252 + test "changing the filter returns to page 1", %{conn: conn, user: user} do
253 + _ = many_repos(user, GitGudWeb.RepoLive.Index.per_page() + 1)
254 +
255 + {:ok, lv, _html} = live(log_in_user(conn, user), ~p"/repositories/2")
256 +
257 + html = lv |> form("form[phx-change=filter]", %{query: "paged"}) |> render_change()
258 +
259 + assert html =~ "page 1 of 2"
260 + end
261 +
262 + test "the page is a path segment, not a query parameter", %{conn: conn, user: user} do
263 + _ = many_repos(user, GitGudWeb.RepoLive.Index.per_page() + 1)
264 +
265 + {:ok, lv, _html} = live(log_in_user(conn, user), ~p"/repositories")
266 +
267 + assert has_element?(lv, ~s{nav[aria-label=Pagination] a[href="/repositories/2"]})
268 + end
269 +
270 + test "a filtered page keeps the filter in the query string", %{conn: conn, user: user} do
271 + _ = many_repos(user, GitGudWeb.RepoLive.Index.per_page() + 1)
272 +
273 + {:ok, lv, _html} = live(log_in_user(conn, user), ~p"/repositories?query=paged")
274 +
275 + assert has_element?(
276 + lv,
277 + ~s{nav[aria-label=Pagination] a[href="/repositories/2?query=paged"]}
278 + )
279 + end
280 +
281 + test "an unfiltered first page keeps a bare URL", %{conn: conn, user: user} do
282 + _ = many_repos(user, GitGudWeb.RepoLive.Index.per_page() + 1)
283 +
284 + {:ok, lv, _html} = live(log_in_user(conn, user), ~p"/repositories/2")
285 +
286 + # Back to page 1 — the link carries no leftover defaults.
287 + assert lv |> element("nav[aria-label=Pagination] a", "1") |> render_click() =~ "page 1 of 2"
288 + end
289 + end
179 290 end

Parents: 71e7254