diff --git a/README.md b/README.md index 5a81259..61b72af 100644 --- a/README.md +++ b/README.md @@ -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: @@ -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). diff --git a/lib/next_page.rb b/lib/next_page.rb index 1237ce5..2f93df0 100644 --- a/lib/next_page.rb +++ b/lib/next_page.rb @@ -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 diff --git a/lib/next_page/pagination.rb b/lib/next_page/pagination.rb new file mode 100644 index 0000000..2a4d6ba --- /dev/null +++ b/lib/next_page/pagination.rb @@ -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 diff --git a/lib/next_page/pagination_attributes.rb b/lib/next_page/pagination_attributes.rb new file mode 100644 index 0000000..8633f9c --- /dev/null +++ b/lib/next_page/pagination_attributes.rb @@ -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 diff --git a/lib/next_page/paginator.rb b/lib/next_page/paginator.rb new file mode 100644 index 0000000..26d219f --- /dev/null +++ b/lib/next_page/paginator.rb @@ -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 diff --git a/spec/controllers/jerseys_controller_spec.rb b/spec/controllers/jerseys_controller_spec.rb index 03e964e..77f30dc 100644 --- a/spec/controllers/jerseys_controller_spec.rb +++ b/spec/controllers/jerseys_controller_spec.rb @@ -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 diff --git a/spec/controllers/uniforms_controller_spec.rb b/spec/controllers/uniforms_controller_spec.rb new file mode 100644 index 0000000..ea9e99b --- /dev/null +++ b/spec/controllers/uniforms_controller_spec.rb @@ -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 diff --git a/spec/dummy/app/controllers/jerseys_controller.rb b/spec/dummy/app/controllers/jerseys_controller.rb index 4b2b4e0..7d2a134 100644 --- a/spec/dummy/app/controllers/jerseys_controller.rb +++ b/spec/dummy/app/controllers/jerseys_controller.rb @@ -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 @@ -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 diff --git a/spec/dummy/app/controllers/uniforms_controller.rb b/spec/dummy/app/controllers/uniforms_controller.rb new file mode 100644 index 0000000..ab57149 --- /dev/null +++ b/spec/dummy/app/controllers/uniforms_controller.rb @@ -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 diff --git a/spec/dummy/config/routes.rb b/spec/dummy/config/routes.rb index 6c44d06..205082e 100644 --- a/spec/dummy/config/routes.rb +++ b/spec/dummy/config/routes.rb @@ -1,4 +1,5 @@ Rails.application.routes.draw do - resources :jerseys - # For details on the DSL available within this file, see https://guides.rubyonrails.org/routing.html + resources :jerseys, only: :index + resources :uniforms, only: :index + get :home_jerseys, to: 'jerseys#home_jerseys' end diff --git a/spec/next_page/pagination_attributes_spec.rb b/spec/next_page/pagination_attributes_spec.rb new file mode 100644 index 0000000..646db17 --- /dev/null +++ b/spec/next_page/pagination_attributes_spec.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe NextPage::PaginationAttributes do + let(:instance) { relation.tap { |cp| cp.extend(described_class) } } + + context 'with limit 5' do + let(:relation) { Jersey.limit(5) } + + it '#total_pages' do + expect(instance.total_pages).to eq 4 + end + end + + context 'with offset 3' do + let(:relation) { Jersey.offset(3) } + + it '#current_page' do + expect(instance.current_page).to eq 4 + end + + it '#next_page' do + expect(instance.next_page).to eq 5 + end + end +end diff --git a/spec/next_page/paginator_spec.rb b/spec/next_page/paginator_spec.rb new file mode 100644 index 0000000..165f9c3 --- /dev/null +++ b/spec/next_page/paginator_spec.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe NextPage::Paginator do +end diff --git a/spec/requests/jerseys_spec.rb b/spec/requests/jerseys_spec.rb deleted file mode 100644 index d69c7da..0000000 --- a/spec/requests/jerseys_spec.rb +++ /dev/null @@ -1,12 +0,0 @@ -# frozen_string_literal: true - -require 'rails_helper' - -RSpec.describe 'Jerseys', type: :request do - describe 'GET /jerseys' do - it 'works! (now write some real specs)' do - get jerseys_path - expect(response).to have_http_status(200) - end - end -end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index e909ec5..20d0656 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -2,7 +2,10 @@ require 'simplecov' -SimpleCov.start +SimpleCov.start do + add_filter 'rails_helper.rb' + add_filter '/spec/' +end RSpec.configure do |config| config.expect_with :rspec do |expectations|