Newer
Older
dmpopidor / app / controllers / concerns / paginable.rb
@Gavin Morrice Gavin Morrice on 27 Sep 2018 6 KB Update paginable.rb
# frozen_string_literal: true

module Paginable

  extend ActiveSupport::Concern

  ##
  # Regex to validate sort_field param is safe
  SORT_COLUMN_FORMAT = /[\w\_]+\.[\w\_]/

  PAGINATION_QUERY_PARAMS = [:page, :sort_field, :sort_direction,
                             :search, :controller, :action]

  private

  # Renders paginable layout with the partial view passed
  #
  # partial       - A String, represents a path to where the partial view is stored
  # controller    - A String, represents the name of the controller to handles the
  #                 pagination
  # action        - A String, represents the action name within the controller
  # path_params   - A Hash of additional URL path parameters
  #                 (e.g. path_paths = { id: 'foo' } for
  #                 /paginable/templates/:id/history/:page)
  # query_params  - A hash of query parameters used to merge with params object
  #                 from the controller for which this concern is included
  # scope         - An {ActiveRecord::Relation}, represents scope variable
  # locals        - A Hash objects with any additional local variables to be passed to
  #                 the partial view
  #
  # Returns String of valid HTML
  # Raises ArgumentError
  def paginable_renderise(partial: nil, controller: nil, action: nil,
                          path_params: {}, query_params: {}, scope: nil,
                          locals: {}, **options)
    unless scope.is_a?(ActiveRecord::Relation)
      raise ArgumentError, _("scope should be an ActiveRecord::Relation object")
    end
    unless path_params.is_a?(Hash)
      raise ArgumentError, _("path_params should be a Hash object")
    end
    unless query_params.is_a?(Hash)
      raise ArgumentError, _("query_params should be a Hash object")
    end
    unless locals.is_a?(Hash)
      raise ArgumentError, _("locals should be a Hash object")
    end

    # Default options
    @paginable_options = {}.merge(options)
    @paginable_options[:view_all] = options.fetch(:view_all, true)
    # Assignment for paginable_params based on arguments passed to the method
    @paginable_params = params.symbolize_keys
    @paginable_params[:controller] = controller if controller
    @paginable_params[:action] = action if action
    # if duplicate keys, those from @paginable_params take precedence
    @paginable_params = query_params.symbolize_keys.merge(@paginable_params)
    # Additional path_params passed to this function got special treatment
    # (e.g. it is taking into account when building base_url)
    @paginable_path_params = path_params.symbolize_keys
    if @paginable_params[:page] == "ALL" &&
       @paginable_params[:search].blank? &&
       @paginable_options[:view_all] == false
      render(
        status: :forbidden,
        html: _("Restricted access to View All the records")
      )
    else
      @refined_scope = refine_query(scope)
      render(layout: "/layouts/paginable",
        partial: partial,
        locals: locals.merge(
          scope: @refined_scope,
          search_term: @paginable_params[:search])
      )
    end
  end

  # Returns the base url of the paginable route for a given page passed
  def paginable_base_url(page = 1)
    url_params = @paginable_path_params.merge(
      controller: @paginable_params[:controller],
      action: @paginable_params[:action],
      page: page
    )
    url_for(url_params)
  end

  # Generates an HTML link to sort given a sort field.
  # sort_field {String} - Represents the column name for a table
  def paginable_sort_link(sort_field)
    link_to(
      sort_link_name(sort_field),
      sort_link_url(sort_field),
      class: "paginable-action",
      data: { remote: true },
      aria: { label: sort_field }
    )
  end

  # Determines whether or not the latest request included the search functionality
  def searchable?
    @paginable_params[:search].present?
  end

  # Determines whether or not the scoped query is paginated or not
  def paginable?
    @refined_scope.respond_to?(:total_pages)
  end

  # Refine a scope passed to this concern if any of the params (search,
  # sort_field or page) are present
  def refine_query(scope)
    if @paginable_params[:search].present?
      scope = scope.search(@paginable_params[:search])
    end
    # Can raise NoMethodError if the scope does not define a search method
    if @paginable_params[:sort_field].present?
      unless @paginable_params[:sort_field][SORT_COLUMN_FORMAT]
        raise ArgumentError, "sort_field param looks unsafe"
      end
      # Can raise ActiveRecord::StatementInvalid (e.g. column does not
      # exist, ambiguity on column, etc)
      scope = scope.order("#{@paginable_params[:sort_field]} #{sort_direction}")
    end
    if @paginable_params[:page] != "ALL"
      # Can raise error if page is not a number
      scope = scope.page(@paginable_params[:page])
    end
    scope
  end

  def sort_direction
    @sort_direction ||= SortDirection.new(@paginable_params[:sort_direction])
  end

  # Returns the sort link name for a given sort_field. The link name includes
  # html prevented of being escaped
  def sort_link_name(sort_field)
    class_name = "fa-sort"
    if @paginable_params[:sort_field] == sort_field
      class_name = "fa-sort-#{sort_direction.downcase}"
    end
    <<~HTML.html_safe
      <i class="fa #{class_name}"
         aria-hidden="true"
         style="float: right; font-size: 1.2em;">

        <span class="screen-reader-text">
          #{_("Sort by %{sort_field}") % { sort_field: sort_field.split(".").first }}
        </span>
      </i>
    HTML
  end

  # Returns the sort url for a given sort_field.
  def sort_link_url(sort_field)
    query_params = {}
    query_params[:page] = @paginable_params[:page] == "ALL" ? "ALL" : 1
    query_params[:sort_field] = sort_field
    if @paginable_params[:sort_field] == sort_field
      query_params[:sort_direction] = sort_direction.opposite
    else
      query_params[:sort_direction] = sort_direction
    end
    base_url = paginable_base_url(query_params[:page])
    sort_url = URI(base_url)
    sort_url.query = stringify_query_params(query_params)
    sort_url.to_s
    "#{sort_url}&#{stringify_nonpagination_query_params}"
  end

  # Retrieve any query params that are not a part of the paginable concern
  def stringify_nonpagination_query_params
    @paginable_params.except(*PAGINATION_QUERY_PARAMS).to_param
  end

  def stringify_query_params(page: 1, search: @paginable_params[:search],
    sort_field: @paginable_params[:sort_field],
    sort_direction: nil)

    query_string = {}
    query_string["search"] = search if search.present?
    if sort_field.present?
      query_string["sort_field"] = sort_field
      query_string["sort_direction"] = SortDirection.new(sort_direction)
    end
    query_string.to_param
  end

end