diff --git a/app/controllers/concerns/paginable.rb b/app/controllers/concerns/paginable.rb index a2f87b5..6d28823 100644 --- a/app/controllers/concerns/paginable.rb +++ b/app/controllers/concerns/paginable.rb @@ -1,145 +1,163 @@ module Paginable extend ActiveSupport::Concern - included do - # Renders paginable layout with the partial view passed - # partial {String} - Represents a path to where the partial view is stored - # controller {String} - Represents the name of the controller to handles the pagination - # action {String} - Represents the method name within the controller - # path_params {Hash} - A hash of additional URL path parameters (e.g. path_paths = { id: 'foo' } for /paginable/templates/:id/history/:page) - # query_params {Hash} - A hash of query parameters used to merge with params object from the controller for which this concern is included - # scope {ActiveRecord::Relation} - Represents scope variable - # locals {Hash} - A hash objects with any additional local variables to be passed to the partial view - def paginable_renderise(partial: nil, controller: nil, action: nil, path_params: {}, query_params: {}, scope: nil, locals: {}, **options) - raise ArgumentError, _('scope should be an ActiveRecord::Relation object') unless scope.is_a?(ActiveRecord::Relation) - raise ArgumentError, _('path_params should be a Hash object') unless path_params.is_a?(Hash) - raise ArgumentError, _('query_params should be a Hash object') unless query_params.is_a?(Hash) - raise ArgumentError, _('locals should be a Hash object') unless locals.is_a?(Hash) + # frozen_string_literal: true - # 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 - @paginable_params = query_params.symbolize_keys.merge(@paginable_params) # if duplicate keys, those from @paginable_params take precedence - # 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) - return url_for(@paginable_path_params.merge({ controller: @paginable_params[:controller], - action: @paginable_params[:action], page: page })) - end - # Returns the base url of the paginable router for a given page passed together with its query_params. - # It is used to retain context, i.e. search, sort_field, sort_direction, etc - def paginable_base_url_with_query_params(page: 1, **stringify_query_params_options) - base_url = paginable_base_url(page) - stringified_query_params = stringify_query_params(stringify_query_params_options) - if stringified_query_params.present? - return "#{base_url}?#{stringified_query_params}" - end - return base_url - 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) - return link_to(sort_link_name(sort_field), sort_link_url(sort_field), 'data-remote': true, class: 'paginable-action', "aria-label": "#{sort_field}") - end - # Determines whether or not the latest request included the search functionality - def searchable? - return @paginable_params[:search].present? - end - # Determines whether or not the scoped query is paginated or not - def paginable? - return @refined_scope.respond_to?(:total_pages) + ## + # Regex to validate sort_field param is safe + SORT_COLUMN_FORMAT = /[\w\_]+\.[\w\_]/ + + + # Renders paginable layout with the partial view passed + # partial {String} - Represents a path to where the partial view is stored + # controller {String} - Represents the name of the controller to handles the pagination + # action {String} - Represents the method name within the controller + # path_params {Hash} - A hash of additional URL path parameters (e.g. path_paths = { id: 'foo' } for /paginable/templates/:id/history/:page) + # query_params {Hash} - A hash of query parameters used to merge with params object from the controller for which this concern is included + # scope {ActiveRecord::Relation} - Represents scope variable + # locals {Hash} - A hash objects with any additional local variables to be passed to the partial view + def paginable_renderise(partial: nil, controller: nil, action: nil, path_params: {}, query_params: {}, scope: nil, locals: {}, **options) + raise ArgumentError, _('scope should be an ActiveRecord::Relation object') unless scope.is_a?(ActiveRecord::Relation) + raise ArgumentError, _('path_params should be a Hash object') unless path_params.is_a?(Hash) + raise ArgumentError, _('query_params should be a Hash object') unless query_params.is_a?(Hash) + raise ArgumentError, _('locals should be a Hash object') unless locals.is_a?(Hash) + + # 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 + @paginable_params = query_params.symbolize_keys.merge(@paginable_params) # if duplicate keys, those from @paginable_params take precedence + # 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) + return url_for(@paginable_path_params.merge({ controller: @paginable_params[:controller], + action: @paginable_params[:action], page: page })) + end + + # Returns the base url of the paginable router for a given page passed together with its query_params. + # It is used to retain context, i.e. search, sort_field, sort_direction, etc + def paginable_base_url_with_query_params(page: 1, **stringify_query_params_options) + base_url = paginable_base_url(page) + stringified_query_params = stringify_query_params(stringify_query_params_options) + if stringified_query_params.present? + return "#{base_url}?#{stringified_query_params}" + end + return base_url + 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) + return link_to(sort_link_name(sort_field), sort_link_url(sort_field), 'data-remote': true, class: 'paginable-action', "aria-label": "#{sort_field}") + end + + # Determines whether or not the latest request included the search functionality + def searchable? + return @paginable_params[:search].present? + end + + # Determines whether or not the scoped query is paginated or not + def paginable? + return @refined_scope.respond_to?(:total_pages) + end + private - # Returns the upcase string (e.g ASC or DESC) if sort_direction param is present in any of the forms 'asc', 'desc', 'ASC', 'DESC' - # otherwise returns ASC - def upcasing_sort_direction(direction = @paginable_params[:sort_direction]) - directions = ['asc', 'desc', 'ASC', 'DESC'] - return directions.include?(direction) ? direction.upcase : 'ASC' - end - # Returns DESC when ASC is passed and vice versa, otherwise nil - def swap_sort_direction(direction = @paginable_params[:sort_direction]) - direction_upcased = upcasing_sort_direction(direction) - return 'DESC' if direction_upcased == 'ASC' - return 'ASC' if direction_upcased == 'DESC' - end - # Refine a scope passed to this concern if any of the params (search, - # sort_field or page) are present - def refine_query(scope) - scope = scope.search(@paginable_params[:search]) if @paginable_params[:search].present? - # Can raise NoMethodError if the scope does not define a search method - if @paginable_params[:sort_field].present? - # Can raise ActiveRecord::StatementInvalid (e.g. column does not - # exist, ambiguity on column, etc) - order_sql = ActiveRecord::Base.sanitize("#{@paginable_params[:sort_field]} #{upcasing_sort_direction}") - scope = scope.order(order_sql) - end - if @paginable_params[:page] != 'ALL' - # Can raise error if page is not a number - scope = scope.page(@paginable_params[:page]) - end - return scope - end + # Returns the upcase string (e.g ASC or DESC) if sort_direction param is present in any of the forms 'asc', 'desc', 'ASC', 'DESC' + # otherwise returns ASC + def upcasing_sort_direction(direction = @paginable_params[:sort_direction]) + directions = ['asc', 'desc', 'ASC', 'DESC'] + return directions.include?(direction) ? direction.upcase : 'ASC' + end + # Returns DESC when ASC is passed and vice versa, otherwise nil + def swap_sort_direction(direction = @paginable_params[:sort_direction]) + direction_upcased = upcasing_sort_direction(direction) + return 'DESC' if direction_upcased == 'ASC' + return 'ASC' if direction_upcased == 'DESC' + 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) - className = 'fa-sort' - if @paginable_params[:sort_field] == sort_field - className = upcasing_sort_direction == 'ASC'? 'fa-sort-asc' : 'fa-sort-desc' + # Refine a scope passed to this concern if any of the params (search, + # sort_field or page) are present + def refine_query(scope) + scope = scope.search(@paginable_params[:search]) if @paginable_params[:search].present? + # 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 - return raw("Sort by #{sort_field.split('.').first}") + # Can raise ActiveRecord::StatementInvalid (e.g. column does not + # exist, ambiguity on column, etc) + scope = scope.order("#{@paginable_params[:sort_field]} #{upcasing_sort_direction}") end - # Returns the sort url for a given sort_field. - def sort_link_url(sort_field) - page = @paginable_params[:page] == 'ALL' ? 'ALL' : 1 - if @paginable_params[:sort_field] == sort_field - sort_url = paginable_base_url_with_query_params( - page: page, - sort_field: sort_field, - sort_direction: swap_sort_direction) - else - sort_url = paginable_base_url_with_query_params( + if @paginable_params[:page] != 'ALL' + # Can raise error if page is not a number + scope = scope.page(@paginable_params[:page]) + end + return scope + 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) + className = 'fa-sort' + if @paginable_params[:sort_field] == sort_field + className = upcasing_sort_direction == 'ASC'? 'fa-sort-asc' : 'fa-sort-desc' + end + return raw("Sort by #{sort_field.split('.').first}") + end + + # Returns the sort url for a given sort_field. + def sort_link_url(sort_field) + page = @paginable_params[:page] == 'ALL' ? 'ALL' : 1 + if @paginable_params[:sort_field] == sort_field + sort_url = paginable_base_url_with_query_params( page: page, - sort_field: sort_field) - end - return "#{sort_url}#{stringify_nonpagination_query_params}" + sort_field: sort_field, + sort_direction: swap_sort_direction) + else + sort_url = paginable_base_url_with_query_params( + page: page, + sort_field: sort_field) end - # Retrieve any query params that are not a part of the paginable concern - def stringify_nonpagination_query_params - other_params = @paginable_params.select do |param| - ![:page, :sort_field, :sort_direction, :search, :controller, :action].include?(param) - end - return other_params.empty? ? '' : "&#{other_params.collect{ |k, v| "#{k}=#{v}" }.join('&')}" - end - def stringify_query_params( - search: @paginable_params[:search], - sort_field: @paginable_params[:sort_field], - sort_direction: nil) + return "#{sort_url}#{stringify_nonpagination_query_params}" + end - query_string = [] - query_string << "search=#{search}" if search.present? - if sort_field.present? - query_string << "sort_field=#{sort_field}" - direction = sort_direction || upcasing_sort_direction - query_string << "sort_direction=#{direction}" - end - return query_string.join('&') + # Retrieve any query params that are not a part of the paginable concern + def stringify_nonpagination_query_params + other_params = @paginable_params.select do |param| + ![:page, :sort_field, :sort_direction, :search, :controller, :action].include?(param) end + return other_params.empty? ? '' : "&#{other_params.collect{ |k, v| "#{k}=#{v}" }.join('&')}" + end + + def stringify_query_params( + 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}" + direction = sort_direction || upcasing_sort_direction + query_string << "sort_direction=#{direction}" + end + return query_string.join('&') + end end \ No newline at end of file