I'm writing this for the same reason I wrote about creating a sitemap.xml, I could find only solutions using libs, but it's simple, we don't need a lib to do this. So I made this solution and I hope it helps you.
First, we can create a new file like lib/myapp/paginator.ex
:
defmodule MyApp.Paginator do
@moduledoc """
Paginate your Ecto queries.
Instead of using: `Repo.all(query)`, you can use: `Paginator.page(query)`.
To change the page you can pass the page number as the second argument.
## Examples
iex> Paginator.paginate(query, 1)
[%Item{id: 1}, %Item{id: 2}, %Item{id: 3}, %Item{id: 4}, %Item{id: 5}]
iex> Paginator.paginate(query, 2)
[%Item{id: 6}, %Item{id: 7}, %Item{id: 8}, %Item{id: 9}, %Item{id: 10}]
"""
import Ecto.Query
alias MyApp.Repo
@results_per_page 12
def paginate(query, page) when is_nil(page) do
paginate(query, 1)
end
def paginate(query, page) when is_binary(page) do
paginate(query, String.to_integer(page))
end
def paginate(query, page) do
results = execute_query(query, page)
total_results = count_total_results(query)
total_pages = count_total_pages(total_results)
%{
current_page: page,
results_per_page: @results_per_page,
total_pages: total_pages,
total_results: total_results,
list: results
}
end
defp execute_query(query, page) do
query
|> limit(^@results_per_page)
|> offset((^page - 1) * ^@results_per_page)
|> Repo.all()
end
defp count_total_results(query) do
Repo.aggregate(query, :count, :id)
end
defp count_total_pages(total_results) do
total_pages = ceil(total_results / @results_per_page)
if total_pages > 0, do: total_pages, else: 1
end
end
Now we'll create the logic to be used in the front-end. So we can create a file like lib/myapp_web/helpers/paginator_helper.ex
:
defmodule MyAppWeb.Helpers.PaginatorHelper do
@moduledoc """
Renders the pagination with a previous button, the pages, and the next button.
"""
use Phoenix.HTML
def render(conn, data, class: class) do
first = prev_button(conn, data)
pages = page_buttons(conn, data)
last = next_button(conn, data)
content_tag(:ul, [first, pages, last], class: class)
end
defp prev_button(conn, data) do
page = data.current_page - 1
disabled = data.current_page == 1
params = build_params(conn, page)
content_tag(:li, disabled: disabled) do
link to: "?#{params}", rel: "prev" do
"<"
end
end
end
defp page_buttons(conn, data) do
for page <- 1..data.total_pages do
class = if data.current_page == page, do: "active"
disabled = data.current_page == page
params = build_params(conn, page)
content_tag(:li, class: class, disabled: disabled) do
link(page, to: "?#{params}")
end
end
end
defp next_button(conn, data) do
page = data.current_page + 1
disabled = data.current_page >= data.total_pages
params = build_params(conn, page)
content_tag(:li, disabled: disabled) do
link to: "?#{params}", rel: "next" do
">"
end
end
end
defp build_params(conn, page) do
conn.query_params |> Map.put(:page, page) |> URI.encode_query()
end
end
And it's basically all we need.
When the page isn't available, it will be disabled
. I also added some CSS like this:
.paginator-list li[disabled] a {
opacity: .4;
pointer-events: none;
}
So now let's suppose we have a method to list all the posts in a blog, it'll be something like this:
alias MyApp.Paginator
def list_paged_posts(params) do
Paginator.paginate(Post, params["page"])
end
And in our Controller you can use this:
def index(conn, params)
posts = Post.list_paged_posts(params)
render(conn, "index.html", posts: posts)
end
And in our HTML you can use this:
<%= for post <- @posts.list do %>
<%= post.title %>
<% end %>
<%= MyApp.Helpers.PaginatorHelper.render(@conn, @posts, class: "paginator-list") %>
And that's all :)
But don't forget the tests! So, we can create the file test/myapp/paginator_test.exs
:
defmodule MyApp.PaginatorTest do
use MyApp.DataCase
alias MyApp.Post
alias MyApp.Paginator
describe "when the page is nil" do
test "sets the page to the first page" do
create_posts(1)
paginator = Paginator.paginate(Post, nil)
assert paginator.current_page == 1
end
end
describe "when the page is a string" do
test "sets the page to an integer" do
create_posts(1)
paginator = Paginator.paginate(Post, "1")
assert paginator.current_page == 1
end
end
test "paginate as 12 results per page" do
create_posts(15)
paginator_first_page = Paginator.paginate(Post, 1)
assert length(paginator_first_page.list) == 12
paginator_second_page = Paginator.paginate(Post, 2)
assert length(paginator_second_page.list) == 3
end
test "prints pagination info" do
posts = create_posts(10)
paginator = Paginator.paginate(Post, 1)
assert paginator.current_page == 1
assert paginator.results_per_page == 12
assert paginator.total_pages == 1
assert paginator.total_results == 10
Enum.each(posts, fn post ->
assert post in paginator.list
end)
end
defp create_posts(quantity) do
for n <- 1..quantity do
# Here you create a new Post!
post_fixture()
end
end
end
And also the test file for the paginator_helper in test/myapp_web/helpers/paginator_helper_test.exs
with something like this:
defmodule MyAppWeb.Helpers.PaginatorHelperTest do
use MyAppWeb.ConnCase
import Phoenix.HTML, only: [safe_to_string: 1]
alias MyApp.Post
alias MyAppWeb.Helpers.PaginatorHelper
describe "render/3" do
test "renders the paginator" do
conn = get(build_conn(), "/?q=paginator+elixir")
paginated_results = Posts.list_paged_posts(%{page: 1})
paginator =
conn
|> PaginatorHelper.render(paginated_results, class: "paginator")
|> safe_to_string()
assert paginator ==
"<ul class=\"paginator\">" <>
"<li disabled>" <>
"<a href=\"?page=0&q=paginator+elixir\" rel=\"prev\"><</a>" <>
"</li>" <>
"<li class=\"active\" disabled>" <>
"<a href=\"?page=1&q=paginator+elixir\">1</a>" <>
"</li>" <>
"<li disabled>" <>
"<a href=\"?page=2&q=paginator+elixir\" rel=\"next\">></a>" <>
"</li>" <>
"</ul>"
end
end
end
And that's it. Hope it helps you :)
Top comments (2)
If the query you want to paginate already contains a
:limit
or an:offset
clause, you may get wrong results because you would override these clauses when applying pagination. The solution is to use a sub-query. You need to modifyexecute_query
like this:I've been debating if I should roll my own pagination or use something like scrivener myself.. This seems like a good, not bloat inducing solution, because while everyone obviously loves endless scrolling and whatnot, it's not the best solution often.