berrypod/lib/berrypod/pagination.ex
jamey 3480b326a9
All checks were successful
deploy / deploy (push) Successful in 1m38s
add pagination across all admin and shop views
URL-based offset pagination with ?page=N for bookmarkable pages.
Admin views use push_patch, shop collection uses navigate links.
Responsive on mobile with horizontal-scroll tables and stacking
pagination controls. Includes dev seed script for testing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 09:42:34 +00:00

120 lines
3.0 KiB
Elixir

defmodule Berrypod.Pagination do
@moduledoc """
Offset-based pagination for Ecto queries.
Returns a `%Berrypod.Pagination{}` struct with items, page metadata,
and total count. Context modules pipe their filtered/ordered queries
into `paginate/2` instead of `Repo.all/0`.
"""
import Ecto.Query
alias Berrypod.Repo
@enforce_keys [:items, :page, :per_page, :total_count, :total_pages]
defstruct [:items, :page, :per_page, :total_count, :total_pages]
@type t :: %__MODULE__{
items: [any()],
page: pos_integer(),
per_page: pos_integer(),
total_count: non_neg_integer(),
total_pages: non_neg_integer()
}
@doc """
Paginates an Ecto query.
Options:
* `:page` - current page (default 1, clamped to valid range)
* `:per_page` - items per page (default 25)
"""
def paginate(query, opts \\ []) do
page = max(opts[:page] || 1, 1)
per_page = opts[:per_page] || 25
count_query =
query
|> exclude(:preload)
|> exclude(:select)
|> exclude(:order_by)
total_count = Repo.aggregate(count_query, :count)
total_pages = max(ceil(total_count / per_page), 1)
page = min(page, total_pages)
items =
query
|> limit(^per_page)
|> offset(^((page - 1) * per_page))
|> Repo.all()
%__MODULE__{
items: items,
page: page,
per_page: per_page,
total_count: total_count,
total_pages: total_pages
}
end
@doc """
Returns "Showing X\u2013Y of Z" for display, or "No results" when empty.
"""
def showing_text(%__MODULE__{total_count: 0}), do: "No results"
def showing_text(%__MODULE__{} = p) do
first = (p.page - 1) * p.per_page + 1
last = min(p.page * p.per_page, p.total_count)
"Showing #{first}\u2013#{last} of #{p.total_count}"
end
@doc """
Computes page numbers with `:ellipsis` gaps for display.
Always shows first, last, and a window around the current page.
Returns a flat list like `[1, :ellipsis, 4, 5, 6, :ellipsis, 20]`.
"""
def page_numbers(%__MODULE__{total_pages: total}) when total <= 7 do
Enum.to_list(1..total)
end
def page_numbers(%__MODULE__{page: current, total_pages: total}) do
window =
[current - 1, current, current + 1]
|> Enum.filter(&(&1 >= 1 and &1 <= total))
pages = Enum.uniq([1] ++ window ++ [total]) |> Enum.sort()
# Insert :ellipsis between non-consecutive numbers
pages
|> Enum.chunk_every(2, 1, :discard)
|> Enum.flat_map(fn [a, b] ->
if b - a > 1, do: [a, :ellipsis], else: [a]
end)
|> Kernel.++([List.last(pages)])
end
@doc """
Parses a page number from request params. Always returns >= 1.
"""
def parse_page(params, key \\ "page") do
case params[key] do
nil ->
1
val when is_binary(val) ->
case Integer.parse(val) do
{n, _} when n > 0 -> n
_ -> 1
end
val when is_integer(val) and val > 0 ->
val
_ ->
1
end
end
end