All checks were successful
deploy / deploy (push) Successful in 1m38s
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>
120 lines
3.0 KiB
Elixir
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
|