DEV Community

loading...

Create a paginator using Elixir and Phoenix

Ricardo Ruwer
Updated on ・4 min read

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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") %>
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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&amp;q=paginator+elixir\" rel=\"prev\"><</a>" <>
                 "</li>" <>
                 "<li class=\"active\" disabled>" <>
                 "<a href=\"?page=1&amp;q=paginator+elixir\">1</a>" <>
                 "</li>" <>
                 "<li disabled>" <>
                 "<a href=\"?page=2&amp;q=paginator+elixir\" rel=\"next\">></a>" <>
                 "</li>" <>
                 "</ul>"
    end
  end
end

Enter fullscreen mode Exit fullscreen mode

And that's it. Hope it helps you :)

Discussion (2)

Collapse
smaximov profile image
Sergei Maximov • Edited

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 modify execute_query like this:

  defp execute_query(query, page) do
    query
    |> maybe_wrap_in_subquery()
    |> limit(^@results_per_page)
    |> offset((^page - 1) * ^@results_per_page)
    |> Repo.all()
  end

  # no :limit/:offset, so no need to wrap the original query in a subquery
  defp maybe_wrap_in_subquery(%{limit: nil, offset: nil} = query), do: query

  defp maybe_wrap_in_subquery(query), do: subquery(query)
Collapse
sinni800 profile image
sinni800

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.