In many frameworks pagination can be painful. You may have multiple options to choose from, the HTML can be hard to customize, and often the APIs are hard to understand.
In Lucky and Avram, we’ve built in pagination and HTML pagination components so you can get started quickly. You can also copy the HTML components to your app and customize them to your hearts content.
Let’s dive in!
You can paginate records from within an action by including the
Lucky::Paginator::BackendHelpers
in your actions.
It’s usually best to include the helpers in your base actions, such as ApiAction
or BrowserAction
. That way any action that inherits from them has access to the included methods:
# src/actions/browser_action.cr
abstract class BrowserAction < Lucky::Action
include Lucky::Paginator::BackendHelpers
end
We are using
BrowserAction
in this example, but it works the same for ApiAction > or any otherLucky::Action
This module will add the paginate
method to you actions. This is what
you’ll use to paginate your query results.
Pagination is easy-peasy, call paginate
with a query:
class Users::Index < BrowserAction
get "/users" do
pages, users = paginate(UserQuery.new)
html IndexPage, users: users, pages: pages
end
end
In this case pages
is a Lucky::Paginator
with metadata about the
current page, total numbers of pages, and helpers for generating paths to
next and previous pages.
The other variable, users
, is the passed in query (UserQuery.new
)
with a limit
and offset
applied so it returns records for just
the requested page.
paginate
will use the page
param or fallback to page 1
if page
is
not present. The page
param can be a query param, a body param, or a
route param.
Next we’ll go over how to customize the defaults.
By default paginate
returns 25
records per page and uses the page
param to determine which page is requested. But if you want something else,
you can!
There are 2 ways to customize items per page.
You can pass it to the paginate
method:
paginate(UserQuery.new, per_page: 50)
Or you can customize it with a method on your action:
abstract class BrowserAction < Lucky::Action
include Lucky::Paginator::BackendHelpers
def paginator_per_page : Int32
50 # Return a static value
# Or allow using a param with a default
params.get?(:per_page).try(&.to_i) || 50
end
end
As you can see you can customize this however you want. You can use a request headers, static value, param, or whatever custom logic you prefer.
By default paginate
uses the page
param. The param can come from a
query param, route param, or body param. If the page
param is not
there, it will fall back to page 1
.
If this logic is not what you want, you can customize it by creating
a paginator_page
that returns an Int32
abstract class ApiAction < Lucky::Action
include Lucky::Paginator::BackendHelpers
def paginator_page : Int32
request.headers["Page"]?.try(&.to_i) || 1
end
end
You can use headers, params, or whatever custom logic you’d like.
With just 2 lines of code you can render pagination links in your HTML pages.
needs pages : Lucky::Paginator
class Users::IndexPage < MainLayout
needs users : UserQuery
needs pages : Lucky::Paginator # Add this to use the paginator
def content
# Mount one of the built-in components
mount Lucky::Paginator::SimpleNav, pages
end
end
There are 3 bult-in components:
Lucky::Paginator::SimpleNav
uses a ul
and li
tags and no classes for styling.Lucky::Paginator::BootstrapNav
uses Bootstrap pagination HTML and classes.Lucky::Paginator::BulmaNav
uses Bulma pagination HTML and classes.The SimpleNav
component will almost certainly need to be customized to
fit your websites theme, but any of the components can be customized or
used as an example for something you build from scratch.
lucky gen.component PaginationLinks
And that’s it! You can mount it like any other component mount PaginationsLinks, page
and customize the HTML and classes as much as
you’d like.
The Lucky::Paginator
object can help to add pagination metadata to JSON
responses.
This is likely the best approach since you don’t need to modify the JSON response at all.
class Api::Users::Index < ApiAction
get "/api/users/index" do
pages, users = paginate(UserQuery.new)
response.headers["Next-Page"] = pages.path_to_next.to_s
response.headers["Total-Pages"] = pages.total.to_s
json UserSerializer.for_collection(users)
end
end
We’ll go into more details about all the methods on Lucky::Paginator
later, but this simple example shows how you can use the paginator object
to provide helpful pagination data in your API responses.
This is a simple example that returns some metadata as JSON.
class Api::Users::Index < ApiAction
get "/api/users/index" do
# We'll skip returning the users for this example
pages, _users = paginate(UserQuery.new)
json(next_page: pages.path_for_next, total_items: pages.item_count)
end
end
Next, let’s do something a bit more realistic. We’ll modify the
BaseSerializer
to accept a Lucky::Paginator
when rendering a
collection. That way we can add pagination data to every JSON response that
renders a collection.
# src/serializers/base_serializer.cr
abstract class BaseSerializer < Lucky::Serializer
# Add a new 'pages' argument
def self.for_collection(collection : Enumerable, pages : Lucky::Paginator, *args, **named_args)
{
"items" => collection.map do |object|
new(object, *args, **named_args)
end,
# Add pagination metadata to the response
"pagination" => {
next_page: pages.path_to_next,
previous_page: pages.path_to_previous,
total_items: pages.item_count,
total_pages: pages.total
}
}
end
end
If you want to customize rendering further, see the collection rendering guide.
Next, we’ll go into details about all the methods available on the
Lucky::Paginator
object.
If you are working with arrays that are not coming from the database, you
can use the paginate_array
method. It will paginate the array and return
the Paginator
object for that array.
array = [1, 2, 3, 5, 6, 7]
pages, numbers = paginate_array(array)
html IndexPage, numbers: numbers, pages: pages
The array can contain all different types, like Int32
, String
, or your
own classes.
The paginate
method returns a Lucky::Paginator
object. This object
contains information about the pages as well as a number of methods for
generating paths to a given page.
page
The page method returns the requested page as an Int32
item_count
The total number of items in the query as an Int32 | Int64
one_page?
Returns true
if there is just 1 page.
last_page?
Returns true
if you are on the last page.
first_page?
Returns true
if you are on the first page.
total
Returns the total number of pages as an Int64
overflowed?
Returns true
if the requested page is past the last page.
next_page
Returns the page
number for the next page.
Returns nil
if there is no next page.
previous_page
Returns the page
number for the previous page.
Returns nil
if there is no previous page.
path_to_next
Returns the path with a ‘page’ query param for the next page.
Returns nil
if there is no next page.
path_to_previous
Returns the path with a ‘page’ query param for the previous page.
Returns nil
if there is no previous page.
path_to_page
Returns the path with a ‘page’ query param for the given page.
item_range
Returns the Range
of items on this requested page.
For example if you have 50 records, showing 20 per page, and you are on the 2nd page this method will return a range of 21-40.
You can get the beginning and end by calling begin
or end
on the
returned Range
.
range = pages.item_range
"Showing #{range.begin}-#{range.end} of #{pages.item_count}"
series
Requires its own section 😃
The series
method calculates the series of pages and gaps based on how
many pages there are, and what the current page is. It uses the
begin|left_of_current|right_of_current|end
arguments to customize the
returned series of pages and gaps. The series is made up of
Lucky::Paginator::Gap
, Lucky::Paginator::Page
and
Lucky::Paginator::CurrentPage
objects. This method is typically used to
build pagination links in HTML.
The best way to describe how this works is with an example. Let’s say you have 10 pages of items and you are requesting page 5.
Note we will simplify the objects by using integers and “..” in place of the
Gap|Page|CurrentPage
objects. We’ll show an example with the real objects further down
# 10 pages of items and you are requesting page 5
series = pages.series(begin: 1, left_of_current: 1, right_of_current: 1, end: 1)
series # [1, .., 4, 5, 6, .., 10]
# All args default to 0 so you can leave them off. That means `begin|end`
# are 0 in this example.
series = pages.series(left_of_current: 1, right_of_current: 1)
series # [4, 5, 6]
# The current page is always shown
series = pages.series(begin: 2, end: 2)
series # [1, 2, .., 5, .., 9, 10]
# The `series` method is smart and will not add gaps if there is no gap.
# It will also not add items past the current page.
series = pages.series(begin: 6)
series # [1, 2, 3, 4, 5]
As mentioned above the actual objects in the Array are made up of
Lucky::Paginator::Gap
, Lucky::Paginator::Page
, and
Lucky::Paginator::CurrentPage
objects.
pages.series(begin: 1, end: 1)
# Returns:
# [
# Lucky::Paginator::Page(1),
# Lucky::Paginator::Gap,
# Lucky::Paginator::CurrentPage(5),
# Lucky::Paginator::Gap,
# Lucky::Paginator::Page(10),
# ]
The Page
and CurrentPage
objects have a number
and path
method.
Page#number
returns the number of the page as an Int. The Page#path
method
Return the path to the next page.
The Gap
object has no methods or instance variables. It is there to
represent a “gap” of pages.
These objects make it easy to use method # overloading
or is_a?
to determine how to render each item.
Here’s a quick example that prints strings:
pages.series(begin: 1, end: 1).each do |item|
case item
when Lucky::Paginator::CurrentPage | Lucky::Paginator::Page
pp! item.number # Int32 representing the page number
pp! item.path # "/items?page=2"
when Lucky::Paginator::Gap
puts "..."
end
end
Or use method overloading. This will show an example using Lucky’s HTML methods:
class PageNav < BaseComponent
needs pages : Lucky::Paginator
def render
pages.series(begin: 1, end: 1).each do |item|
page_item(item)
end
end
def page_item(page : Lucky::Paginator::CurrentPage)
# If it is the current page, just display text and no link
text page.number
end
def page_item(page : Lucky::Paginator::CurrentPage)
a page.number, href: page.path
end
def page_item(gap : Lucky::Paginator::Gap)
text ".."
end
end
See the built-in SimpleNav
component
to see series
in action and get some ideas.