Skip to content

Commit

Permalink
Pagination!
Browse files Browse the repository at this point in the history
  • Loading branch information
toddkummer committed May 3, 2020
1 parent 37114e0 commit 07ce62d
Show file tree
Hide file tree
Showing 14 changed files with 366 additions and 28 deletions.
69 changes: 64 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,70 @@
# NextPage
Short description and motivation.
Basic pagination for Rails controllers.

## Usage
How to use my plugin.
Module Pagination provides pagination for index methods. It assigns a limit and offset to the resource query and extends the relation with mixin NextPage::PaginationAttributes to provide helper methods for generating links.

### Include the Module
Add an include statement for the module into any controller that needs pagination:

```ruby
include NextPage::Pagination
```

There are two ways to paginate: using a before filter or by calling `paginate_resource` explicitly.

### Before Filter
Here's an example of using the before filter in a controller:

```ruby
before_action :apply_next_page_pagination, only: :index
```

This entry point uses the following conventions to apply pagination:
- the name of the instance variable is the sames as the component (for example PhotosController -> @photos)
- the name of the models is the controller name singularized (for example PhotosController -> Photo)

Either can be overridden by calling method `paginate_with` in the controller. The two override options are
`instance_variable_name` and `model_class`. For example, if the PhotosController used the model Picture and the
instance variable name @photographs, the controller declares it as follows:

```ruby
paginate_with instance_variable_name: :photographs, model_class: 'Picture'
```

If the before filter is used, it will populate an instance variable. The action should NOT reset the variable, as
that removes pagination.

### Invoking Pagination Directly
To paginate a resource pass the resource into method `paginate_resource` then store the return value back in the
resource:

```ruby
@photos = paginate_resource(@photos)
```


### Default Limit
The default size limit can be overridden with the `paginate_with` method for either type of paginagion. Pass option
`default_limit` to specify an override:

```ruby
paginate_with default_limit: 25
```

All the options can be mixed and matches when calling `paginate_with`:

```ruby
paginate_with model_class: 'Photo', default_limit: 12
paginate_with default_limit: 12, instance_variable_name: 'data'
```

### Link Helpers
This gem does not do any rendering. It does provide helper methods for generating links. The resource will include the following additional methods:
- current_page
- next_page
- total_pages
- per_page

## Installation
Add this line to your application's Gemfile:
Expand All @@ -21,8 +83,5 @@ Or install it yourself as:
$ gem install next_page
```

## Contributing
Contribution directions go here.

## License
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
5 changes: 4 additions & 1 deletion lib/next_page.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
# frozen_string_literal: true

require 'next_page/pagination'
require 'next_page/pagination_attributes'
require 'next_page/paginator'

# = Next Page
module NextPage
# Your code goes here...
end
80 changes: 80 additions & 0 deletions lib/next_page/pagination.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
# frozen_string_literal: true

module NextPage
# = Pagination
#
# Module Pagination provides pagination for index methods. It assigns a limit and offset
# to the resource query and extends the relation with mixin NextPage::PaginationAttributes.
#
# There are two ways to paginate: using a before filter or by calling `paginate_resource` explicitly.
#
# == Before Filter
# Here's an example of using the before filter in a controller:
# before_action :apply_next_page_pagination, only: :index
#
# This entry point uses the following conventions to apply pagination:
# - the name of the instance variable is the same as the component (for example PhotosController -> @photos)
# - the name of the model is the controller name singularized (for example PhotosController -> Photo)
#
# Either can be overridden by calling method `paginate_with` in the controller. The two override options are
# `instance_variable_name` and `model_class`. For example, if the PhotosController used the model Picture and the
# instance variable name @photographs, the controller declares it as follows:
# paginate_with instance_variable_name: :photographs, model_class: 'Picture'
#
# If the before filter is used, it will populate an instance variable. The action should NOT reset the variable, as
# that removes pagination.
#
# == Invoking Pagination Directly
# To paginate a resource pass the resource into method `paginate_resource` then store the return value back in the
# resource:
#
# @photos = paginate_resource(@photos)
#
# == Default Limit
# The default size limit can be overridden with the `paginate_with` method for either type of pagination. Pass option
# `default_limit` to specify an override:
#
# paginate_with default_limit: 25
#
# All the options can be mixed and matches when calling `paginate_with`:
#
# paginate_with model_class: 'Photo', default_limit: 12
# paginate_with default_limit: 12, instance_variable_name: 'data'
module Pagination
extend ActiveSupport::Concern

class_methods do
def next_page_paginator #:nodoc:
@next_page_paginator ||= NextPage::Paginator.new(controller_name, controller_path)
end

# Configure pagination with any of the following options:
# - instance_variable_name: explicitly name the variable if it does not follow the convention
# - model_class: explicitly specify the model name if it does not follow the convention
# - default_limit: specify an alternate default
def paginate_with(instance_variable_name: nil, model_class: nil, default_limit: nil)
next_page_paginator.paginate_with(instance_variable_name, model_class, default_limit)
end
end

# Called with before_action in order to automatically paginate the resource.
def apply_next_page_pagination
self.class.next_page_paginator.paginate(self, params[:page])
end

# Invokes pagination directly, the result must be stored as the resource itself is not modified.
def paginate_resource(resource)
self.class.next_page_paginator.paginate_resource(resource, params[:page])
end

def render(*args) #:nodoc:
return super unless action_name == 'index' && request.headers[:Accept] == 'application/vnd.api+json'

options = args.first
return super unless options.is_a?(Hash) && options.key?(:json)

options[:meta] = options.fetch(:meta, {}).merge!(total_pages: options[:json].total_pages)
super
end
end
end
25 changes: 25 additions & 0 deletions lib/next_page/pagination_attributes.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# frozen_string_literal: true

module NextPage
# = Pagination Attributes
#
# Module PaginationAttributes adds in methods required for pagination links: current_page, next_page, and total_pages.
# It reads the offset and limit on the query to determine the values.
module PaginationAttributes
def current_page
@current_page ||= offset_value + 1
end

def next_page
current_page + 1
end

def total_pages
@total_pages ||= unscope(:limit).count / per_page
end

def per_page
@per_page ||= limit_value
end
end
end
74 changes: 74 additions & 0 deletions lib/next_page/paginator.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
# frozen_string_literal: true

module NextPage
# = Paginator
#
# Class Paginator uses the controller information to determine the model and variable name for the
# request, then applies a limit and offset to the query based upon the parameters or the defaults. It also extends
# the resource with the NextPage::PaginationAttributes mixin.
#
# Configuration can be specified in the controller by calling `paginate_with`. The following overrides can be
# specified if necessary:
# - default_limit: limit to use if request does not specify (default value is 10)
# - instance_variable_name: default value is the controller name; for example, @photos in PhotosController
# - model_class: default derived from controller name (or path if nested); for example, Photo for PhotosController
class Paginator
DEFAULT_LIMIT = 10

def initialize(controller_name, controller_path)
@controller_name = controller_name
@controller_path = controller_path

@default_limit = DEFAULT_LIMIT
end

def paginate_with(instance_variable_name, model_class, default_limit)
@default_limit = default_limit if default_limit.present?
@instance_variable_name = instance_variable_name
@model_class = model_class.is_a?(String) ? model_class.constantize : model_class
end

def paginate(controller, page_params)
name = "@#{instance_variable_name}"
data = controller.instance_variable_get(name) || model_class.all

controller.instance_variable_set(name, paginate_resource(data, page_params))
end

def paginate_resource(data, page_params)
data.extend(NextPage::PaginationAttributes)

limit = page_size(page_params)
offset = page_number(page_params) - 1
data.limit(limit).offset(offset * limit)
end

private

def model_class
@model_class ||= @controller_name.classify.safe_constantize ||
@controller_path.classify.safe_constantize ||
raise('Could not determine model for pagination; please specify using `paginate_with` options.')
end

def instance_variable_name
@instance_variable_name ||= @controller_name
end

def page_size(page)
if page.present? && page[:size].present?
page[:size]&.to_i
else
@default_limit
end
end

def page_number(page)
if page.present? && page[:number].present?
page[:number]&.to_i
else
1
end
end
end
end
33 changes: 28 additions & 5 deletions spec/controllers/jerseys_controller_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,18 +7,41 @@

describe 'GET #index' do
it 'returns a success response' do
request.headers.merge!({ Accept: 'application/vnd.api+json' })
get :index, params: {}
expect(response).to be_successful
end

it 'returns 20 jerseys' do
it 'returns the default limit' do
get :index, params: {}
expect(JSON.parse(response.body).size).to eq 20
expect(JSON.parse(response.body).size).to eq NextPage::Paginator::DEFAULT_LIMIT
end

it 'returns 10 when filtered to home' do
get :index, params: { filter: { home: true } }
expect(JSON.parse(response.body).size).to eq 10
it 'with size: 5' do
get :index, params: { page: { size: 5 } }
expect(JSON.parse(response.body).size).to eq 5
end

it 'with size 12 and page 2' do
get :index, params: { page: { size: 12, number: 2 } }
expect(JSON.parse(response.body).size).to eq 8
end
end

describe 'calling paginate_resource directly' do
it 'returns a success response' do
get :home_jerseys, params: {}
expect(response).to be_successful
end

it 'returns a success response' do
get :home_jerseys, params: { page: { size: 8 } }
expect(JSON.parse(response.body).size).to eq 8
end

it 'with size 4 and page 3' do
get :home_jerseys, params: { page: { size: 4, number: 3 } }
expect(JSON.parse(response.body).size).to eq 2
end
end
end
29 changes: 29 additions & 0 deletions spec/controllers/uniforms_controller_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# frozen_string_literal: true

require 'rails_helper'

RSpec.describe UniformsController, type: :controller do
fixtures :jerseys

describe 'GET #index' do
it 'returns a success response' do
get :index, params: {}
expect(response).to be_successful
end

it 'returns the specified limit' do
get :index, params: {}
expect(JSON.parse(response.body).size).to eq 8
end

it 'with size: 5' do
get :index, params: { page: { size: 5 } }
expect(JSON.parse(response.body).size).to eq 5
end

it 'with size 12 and page 2' do
get :index, params: { page: { size: 12, number: 2 } }
expect(JSON.parse(response.body).size).to eq 8
end
end
end
11 changes: 9 additions & 2 deletions spec/dummy/app/controllers/jerseys_controller.rb
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
# frozen_string_literal: true

class JerseysController < ApplicationController
include NextPage::Pagination
before_action :apply_next_page_pagination, only: :index

# GET /jerseys
def index
@jerseys = Jersey.all

@jerseys = @jerseys.home if params.dig(:filter, :home)
@jerseys = @jerseys.away if params.dig(:filter, :away)

# render with json_api to test the total_pages attribute
render json: @jerseys
end

Expand All @@ -16,4 +18,9 @@ def show
@jersey = Jersey.find(params[:id])
render json: @jersey
end

def home_jerseys
@jerseys = paginate_resource(Jersey.home)
render json: @jerseys
end
end
13 changes: 13 additions & 0 deletions spec/dummy/app/controllers/uniforms_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# frozen_string_literal: true

class UniformsController < ApplicationController
include NextPage::Pagination
before_action :apply_next_page_pagination, only: :index

paginate_with instance_variable_name: :unis, model_class: 'Jersey', default_limit: 8

# GET /uniforms
def index
render json: @unis
end
end
Loading

0 comments on commit 07ce62d

Please sign in to comment.