sn_filterable 0.1.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,97 @@
1
+ <% url = @url || url_for %>
2
+ <div x-data="{ filteringForm: $refs.filtering_form, entireComponenet: $el, filtersLoading: false, filtersPopupOpen: false, sidebarGapTarget: null }">
3
+ <%= content_tag :form,
4
+ "x-ref": "filtering_form",
5
+ "action": url,
6
+ "method": "get",
7
+ "data-turbo-frame": @frame_id,
8
+ "class": "relative",
9
+ "@submit": "filtersLoading = true" do %>
10
+ <div class="absolute w-full h-full pointer-events-none">
11
+ <div class="absolute flex flex-col sm:grid sm:grid-cols-3 gap-4 w-full pointer-events-none">
12
+ <div class="sm:hidden h-14"></div>
13
+ <% if @search_filter_name.present? %>
14
+ <% if search? %>
15
+ <%= search %>
16
+ <% else %>
17
+ <div class="col-span-2 col-start-2 md:ml-48 lg:ml-64 h-12 pointer-events-auto">
18
+ <%= render search_field %>
19
+ </div>
20
+ <% end %>
21
+ <% end %>
22
+ </div>
23
+ <% if @show_sidebar %>
24
+ <div class="mt-16 h-full pointer-events-auto">
25
+ <div class="h-full" x-init="sidebarGapTarget = $el">
26
+ <div x-show="filtersPopupOpen" class="fixed top-0 left-0 right-0 bottom-0 z-10 sm:z-0 bg-gray-600 bg-opacity-75 sm:hidden" @click="filtersPopupOpen = false" x-cloak></div>
27
+ <div x-show="filtersPopupOpen" class="fixed bottom-0 h-2/3 z-10 sm:z-0 left-0 w-full bg-white pt-4 sm:sticky sm:!block row-span-2 shrink-0 mr-4 self-start sm:top-16 lg:w-64 sm:w-52 sm:pt-2 sm:pb-6 sm:overflow-y-auto sm:h-[70vh] sm:max-h-[calc(100vh_-_5rem)]" x-cloak>
28
+ <div class="absolute top-0 right-0 -mt-12 mb-2 mr-2" x-show="filtersPopupOpen">
29
+ <button type="button" class="ml-1 flex items-center justify-center h-10 w-10 rounded-full focus:outline-none focus:ring-2 focus:ring-inset focus:ring-white" @click="filtersPopupOpen = false">
30
+ <span class="sr-only">Close filters</span>
31
+ <%= heroicon "x-mark", variant: :outline, options: { class: "h-6 w-6 text-grey-500" } %>
32
+ </button>
33
+ </div>
34
+ <div name="Filter Options" role="menu" aria-orientation="vertical" aria-labelledby="options-menu" class="overflow-y-auto max-h-full">
35
+ <% @filters.each do |filter| %>
36
+ <%= render SnFilterable::CategoryComponent.new(title: filter[:title], open: @filtered.queries.dig("filter", filter.try(:[], :filter_name)).present?) do |c| %>
37
+ <% c.filter(filtered: @filtered, filter: filter) %>
38
+ <% end %>
39
+ <% end %>
40
+ <%= render SnFilterable::CategoryComponent.new(title: t("shared.filterable.results_per_page"), open: @filtered.queries.dig("per").present?) do %>
41
+ <fieldset class="pt-2" x-data="{ wasInteracted: false }">
42
+ <% ([@filtered.items.default_per_page] | [10, 25, 50]).sort.each do |value| %>
43
+ <div class="relative flex items-start px-4 transition-colors hover:bg-gray-200 text-gray-700 hover:text-gray-900">
44
+ <div class="min-w-0 flex-1 flex-grow">
45
+ <%= content_tag :label, value, class: "block py-2 pr-4 text-gray-600 select-none w-full cursor-pointer", for: "per-#{value}" %>
46
+ </div>
47
+ <div class="my-2 flex items-center">
48
+ <% selected_explcitly = value == @filtered.queries["per"].to_i %>
49
+ <% selected_by_default = @filtered.queries["per"].blank? && value == @filtered.items.default_per_page %>
50
+ <%= content_tag :input, "",
51
+ class: "focus:ring-purple-400 cursor-pointer h-5 w-5 text-purple-500 bg-purple-100 border-0",
52
+ type: "radio",
53
+ id: "per-#{value}",
54
+ "x-data": { iteracted: selected_explcitly }.to_json,
55
+ ":name": "wasInteracted && 'per'",
56
+ value: value,
57
+ checked: selected_explcitly || selected_by_default,
58
+ "@click": "wasInteracted = true; $nextTick(() => { $el.checked = true; filteringForm.requestSubmit() })"
59
+ %>
60
+ </div>
61
+ </div>
62
+ <% end %>
63
+ </fieldset>
64
+ <% end %>
65
+ </div>
66
+ </div>
67
+ </div>
68
+ </div>
69
+ <% end %>
70
+ </div>
71
+ <% end %>
72
+
73
+ <%= turbo_frame_tag @frame_id, "data-turbo-action": "advance" do %>
74
+ <%= content_tag :input, "", type: "hidden", name: "sort", value: @filtered.queries["sort"] if @filtered.queries["sort"].present? %>
75
+ <%= content_tag :input, "", type: "hidden", name: "order", value: @filtered.queries["order"] if @filtered.queries["order"].present? %>
76
+
77
+ <div class="flex flex-col sm:grid sm:grid-cols-3 gap-4 pointer-events-none">
78
+ <div class="sm:hidden flex items-end h-14 pointer-events-auto">
79
+ <%= render SnFilterable::FilterButtonComponent.new(filtered: @filtered, filters: @filters) %>
80
+ </div>
81
+ <% if @search_filter_name.present? %>
82
+ <div class="col-span-2 col-start-2 md:ml-64 h-12 pointer-events-none"></div>
83
+ <% end %>
84
+ </div>
85
+
86
+ <%= render SnFilterable::ChipsComponent.new(filtered: @filtered, filters: @filters, url: url) %>
87
+
88
+ <div class="flex flex-row" x-init="filtersLoading = false">
89
+ <% if @show_sidebar %>
90
+ <div class="lg:w-64 sm:w-52"></div>
91
+ <% end %>
92
+ <div class="relative flex-1 <%= "sm:ml-4" if @show_sidebar %>">
93
+ <%= content %>
94
+ </div>
95
+ </div>
96
+ <% end %>
97
+ </div>
@@ -0,0 +1,39 @@
1
+ module SnFilterable
2
+ require "view_component"
3
+ # Renders the filtering interface
4
+ #
5
+ # ## Filters' info
6
+ # An array of hashes in the following Hash format:
7
+ # - multi: [Boolean]; Determines if this filter supports multiple simultaneous subfilters. If true, the filter must be declared in the [Filterable]'s [ARRAY_FILTER_SCOPES]
8
+ # - title: [String]; The title to display on the filtering interface
9
+ # - filter_name: [String]; The filter's parameter name, see [Filterable]'s documentation
10
+ # - filters: [Array<Hash>]; An array of the available preset sub filters
11
+ # - name: [String]; The name of the sub filter to display in the interface
12
+ # - value: [String]; The value of the sub filter
13
+ class MainComponent < ViewComponent::Base
14
+ include Turbo::FramesHelper
15
+ include Turbo::StreamsHelper
16
+ include Heroicon::Engine.helpers
17
+
18
+ renders_one :search
19
+
20
+ # @param [String] frame_id The unique turbo frame ID
21
+ # @param [Filtered] filtered The filtered instance
22
+ # @param [Array<Hash>] filters An array of the filters' info (see [Filterable::MainComponent]'s documentation)
23
+ # @param [String, nil] url Optional, the base URL of where the filters are displayed
24
+ # @param [String, nil] search_filter_name Optional, enable's and set's the search filter, specified by the filter's parameter name
25
+ # @param [Boolean] show_sidebar If true, will show the sidebar with the filters.
26
+ def initialize(frame_id:, filtered:, filters:, url: nil, search_filter_name: nil, show_sidebar: true)
27
+ @frame_id = frame_id
28
+ @filtered = filtered
29
+ @filters = filters
30
+ @url = url
31
+ @search_filter_name = search_filter_name
32
+ @show_sidebar = show_sidebar
33
+ end
34
+
35
+ def search_field
36
+ SnFilterable::SearchComponent.new(filtered: @filtered, filter_name: @search_filter_name)
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,16 @@
1
+ <div class="mt-1 relative rounded-md shadow-sm">
2
+ <div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
3
+ <%= heroicon "magnifying-glass", options: { class: "h-5 w-5 text-gray-400" } %>
4
+ </div>
5
+ <%= content_tag :input, "",
6
+ class: "focus:ring-indigo-500 focus:border-indigo-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md",
7
+ placeholder: "Search...",
8
+ type: "search",
9
+ ":name": "hasValue && `filter[#{@filter_name}]`",
10
+ value: @filtered.queries.dig("filter", @filter_name),
11
+ "x-data": "filteringSearchInput(#{@filter_name.to_json})",
12
+ "x-init": "onInit()",
13
+ "@input": "onUpdate(false)",
14
+ "@keyup.enter": "onUpdate(true)",
15
+ "@search": "onUpdate(true)" %>
16
+ </div>
@@ -0,0 +1,13 @@
1
+ module SnFilterable
2
+ # Renders the optional search bar
3
+ class SearchComponent < ViewComponent::Base
4
+ include Heroicon::Engine.helpers
5
+
6
+ # @param [Filtered] filtered The filtered instance
7
+ # @param [String] filter_name The search filter's parameter name
8
+ def initialize(filtered:, filter_name:)
9
+ @filtered = filtered
10
+ @filter_name = filter_name
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,171 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Helper class that provides access to the filtered items and generation
4
+ # of sort/filter URLs for the items.
5
+ #
6
+ # Should not be initialized directly, models should implement [Filterable] instead.
7
+ #
8
+ # @see Filterable
9
+ class Filtered
10
+ attr_accessor :items, :queries
11
+
12
+ # @param [Class] model_class The class of the ActiveRecord::Base subclass
13
+ # @param [ActiveRecord::Relation] items The items sorted and filtered by [Filterable]
14
+ # @param [Hash] queries A hash of the sorting / filtering parameters
15
+ # @param [Symbol] sort_name The current sorting name
16
+ # @param [Boolean] sort_reversed True when the current sorting order is reversed
17
+ def initialize(model_class, items, queries, sort_name, sort_reversed)
18
+ @model_class = model_class
19
+ @items = items
20
+ @queries = queries
21
+ @sort_name = sort_name
22
+ @sort_reversed = sort_reversed
23
+ end
24
+
25
+ # Returns if any filters are active
26
+ # @return [Boolean] True if at least one filter is active
27
+ def active_filters?
28
+ @queries["filter"].present?
29
+ end
30
+
31
+ # Sets one filter to the URL.
32
+ #
33
+ # @param [String] url The URL to apply the filter to
34
+ # @param [String] key The filter's key
35
+ # @param [String] value The value to apply using the filter
36
+ # @return [String] the modified URL with the filter applied
37
+ def set_filter_url(url, key, value)
38
+ modify_url_queries(url) do |queries|
39
+ queries["filter"] = { key => value }
40
+ end
41
+ end
42
+
43
+ # Adds a filter to the URL.
44
+ #
45
+ # @param [String] url The URL to add the filter to
46
+ # @param [String] key The filter's key
47
+ # @param [String] value The value to apply using the filter
48
+ # @return [String] the modified URL with the filter added
49
+ def add_filter_url(url, key, value)
50
+ modify_url_queries(url) do |queries|
51
+ queries["filter"][key] = value
52
+ end
53
+ end
54
+
55
+ # Removes a filter from the URL.
56
+ #
57
+ # @param [String] url The URL to remove the filter from
58
+ # @param [String] key The filter's key to remove
59
+ # @return [String] the modified URL with the filter removed
60
+ def remove_filter_url(url, key)
61
+ modify_url_queries(url) do |queries|
62
+ queries["filter"].delete(key)
63
+ end.chomp("?")
64
+ end
65
+
66
+ # Removes a sub filter from the URL.
67
+ #
68
+ # @param [String] url The URL to remove the filter from
69
+ # @param [String] key The filter's key to match
70
+ # @param [String] value The filter's value to remove
71
+ # @return [String] the modified URL with the filter removed
72
+ def remove_sub_filter_url(url, key, value)
73
+ modify_url_queries(url) do |queries|
74
+ queries["filter"][key].delete(value.to_s) if queries["filter"][key].is_a?(Array)
75
+ end.chomp("?")
76
+ end
77
+
78
+ # Clears all filters from the URL.
79
+ #
80
+ # @param [String] url The url to remove the filter from
81
+ # @return [String] the modified URL with all filters removed
82
+ def clear_filter_url(url)
83
+ clear_url(url, true, false)
84
+ end
85
+
86
+ # Clears sorting from the URL.
87
+ #
88
+ # @param [String] url The url to remove the sorting parameters from
89
+ # @return [String] the modified URL with no defined sort
90
+ def clear_sort_url(url)
91
+ clear_url(url, false, true)
92
+ end
93
+
94
+ # Clears all filters and sorting from the URL.
95
+ #
96
+ # @param [String] url The url to remove the sorting and filtering from
97
+ # @return [String] the modified URL with all filters and sorting removed
98
+ def clear_all_url(url)
99
+ clear_url(url, true, true)
100
+ end
101
+
102
+ # Generates a URL used in table headers for column sorting.
103
+ #
104
+ # Calls and returns `block`, providing the following parameters in the following order:
105
+ # - url: [String] The URL for the column sorting which links to the *next* sorting state
106
+ # - state: [nil, Symbol] The *current* sorting state of the provided key. Provides the key's sorting order as a symbol, either `:asc`, or `:desc` when active. Returns `nil`, this sorting key is not active.
107
+ #
108
+ # When the sorting key provided is active for this [Filtered] instance, this method will
109
+ # return a URL with the order reversed.
110
+ #
111
+ # @param [String] url The url to apply the sort parameters on
112
+ # @param [String] key The sorting key to toggle
113
+ # @param [Symbol, nil] order Optional, sets the sorting order. If set to nil, will toggle the order instead.
114
+ # @return the value returned from the block. If no block is given, [Array(String, Symbol | nil)] is returned, which contains the URL and state respectively
115
+ def sort_url(url, key, order = nil, scope: nil)
116
+ state = nil
117
+ url = modify_url_queries(url) do |queries|
118
+ queries["sort"] = key
119
+
120
+ if @sort_name == key
121
+ state = @sort_reversed ? :desc : :asc
122
+ queries["order"] = @sort_reversed ? "asc" : "desc"
123
+ else
124
+ queries.delete("order")
125
+ end
126
+
127
+ queries["order"] = order.to_s unless order.nil?
128
+
129
+ queries["scope"] = scope.to_s unless scope.nil?
130
+ end
131
+
132
+ return yield(url, state) if block_given?
133
+
134
+ [url, state]
135
+ end
136
+
137
+ private
138
+
139
+ # Clears parameters from the URL.
140
+ #
141
+ # @param [String] url The url to remove the filter from
142
+ # @param [Boolean] clear_filter If true, will clear all filters from the URL
143
+ # @param [Boolean] clear_sort If true, will clear the sort parameters from the URL
144
+ # @return [String] the cleared URL
145
+ def clear_url(url, clear_filter, clear_sort)
146
+ modify_url_queries(url) do |queries|
147
+ queries.delete("filter") if clear_filter
148
+
149
+ if clear_sort
150
+ queries.delete("sort")
151
+ queries.delete("order")
152
+ end
153
+ end.chomp("?")
154
+ end
155
+
156
+ # Modifies a URL query parameters by calling `block`,
157
+ # providing the mutable queries as the first parameter.
158
+ #
159
+ # @param url [String] The base URL
160
+ # @return [String] the modified URL
161
+ def modify_url_queries(url)
162
+ uri = URI.parse(url)
163
+ query = Rack::Utils.parse_nested_query(uri.query).deep_merge(@queries.deep_dup)
164
+
165
+ yield(query) if block_given?
166
+
167
+ uri.query = Rack::Utils.build_nested_query(query)
168
+
169
+ uri.to_s
170
+ end
171
+ end
@@ -0,0 +1,10 @@
1
+ require "rails/engine"
2
+ require "view_component/engine"
3
+
4
+ module SnFilterable
5
+ class Engine < Rails::Engine
6
+ config.autoload_paths = %W[
7
+ #{root}/app/components
8
+ ]
9
+ end
10
+ end
@@ -0,0 +1,292 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Concern to add sorting and filtering for ActiveRecord models.
4
+ #
5
+ # The following constants must to be implemented in the base class
6
+ # for filtering and sorting to work:
7
+ #
8
+ # `FILTER_SCOPE_MAPPINGS`: A [Hash] that maps a filter's parameter name to the filter's scope. The scope's proc should have a single argument for filtering. Multiple filters will be ANDed when building the full query.
9
+ #
10
+ # `ARRAY_FILTER_SCOPES`: An [Array] of filter parameter names that support having multiple values. If multiple filters of the same key are provided, the value's scope will be ORed when building the filter's query.
11
+ #
12
+ # `SORT_SCOPE_MAPPINGS`: A [Hash] that maps a sorting name to the sorting scope. If the scope is complex preventing automatic reversal, another scope with the suffix '_reversed' must be made.
13
+ #
14
+ # `DEFAULT_SORT`: Optional, sets the default sort of the items when no sorting parameter is set. Can be either a [String], which returns the sorting name or an [Array], where the first item is the sorting name and the second item is the sort direction (either `:asc` or `:desc`).
15
+ #
16
+ # @see Filtered
17
+ require "pg_search"
18
+
19
+ module SnFilterable
20
+ module Filterable
21
+ extend ActiveSupport::Concern
22
+ include PgSearch::Model
23
+
24
+ class_methods do
25
+ # Filters and sorts the model's items.
26
+ #
27
+ # @param [ActionController::Parameters] params The unfiltered parameters
28
+ # @param [ActiveRecord::Relation] items Optional, the items to scope from the model
29
+ # @param [String, Array, nil] default_sort Optional, similar to the `DEFAULT_SORT` constant, sets the default sort of items when no sorting parameter is set. Can be either a [String], which returns the sorting name or an [Array], where the first item is the sorting name and the second item is the sort direction (either `:asc` or `:desc`). Will take precedence over the `DEFAULT_SORT` constant.
30
+ # @param [Boolean] pagination_enabled Optional, toggles pagination
31
+ # @return [Filtered] the filtered and sorted items
32
+ def filter(params:, items: where(nil), default_sort: nil, pagination_enabled: true)
33
+ filter_params = filter_params(params)
34
+ sort_params = sort_params(params)
35
+ other_params = other_params(params, items)
36
+
37
+ items = perform_filter(items, filter_params)
38
+ items, sort_name, reverse_order = perform_sort(items, sort_params, default_sort)
39
+ items = items.page(other_params[:page]).per(other_params[:per]) if pagination_enabled
40
+
41
+ Filtered.new(self, items, generate_url_queries(filter_params, sort_params, other_params), sort_name, reverse_order)
42
+ end
43
+
44
+ private
45
+
46
+ # Filters the items using the provided parameters. All filters' clauses are ANDed.
47
+ #
48
+ # @param [ActiveRecord::Relation] items
49
+ # @param [ActionController::Parameters] params The filter parameters
50
+ # @return [ActiveRecord::Relation] the filtered items
51
+ def perform_filter(items, params)
52
+ params[:filter].try(:each) do |key, value|
53
+ filter_items = perform_single_filter(items, key, value)
54
+
55
+ items = items.merge(filter_items) unless filter_items.nil?
56
+ end
57
+
58
+ items
59
+ end
60
+
61
+ # Builds the relation for a single filter.
62
+ #
63
+ # All filters are performed against `items.model.all` to build a generic relation against the table.
64
+ # To be used with `#perform_filter` which will reduce the scope of the returned relation by ANDing.
65
+ #
66
+ # When an array of values is given, each value's clause is ORed.
67
+ #
68
+ # @param [ActiveRecord::Relation] items
69
+ # @param [String] key The filtering key
70
+ # @param [String, Array<String>] value The value(s) to sort
71
+ # @return [ActiveRecord::Relation, nil] returns the relation or `nil` if a non-empty value was given
72
+ def perform_single_filter(items, key, value)
73
+ if value.present?
74
+ return items.model.all.public_send(const_get(:FILTER_SCOPE_MAPPINGS)[key.to_sym], value) unless value.is_a?(Array)
75
+
76
+ output_items = nil
77
+
78
+ value.each do |x|
79
+ next if x.blank?
80
+
81
+ filter_items = items.model.all.public_send(const_get(:FILTER_SCOPE_MAPPINGS)[key.to_sym], x)
82
+ output_items = output_items.nil? ? filter_items : output_items.or(filter_items)
83
+ end
84
+
85
+ return output_items
86
+ end
87
+
88
+ nil
89
+ end
90
+
91
+ # Sorts the items using the `sort` and `order` parameters. Will default to using
92
+ # the default sort when the sorting parameters are not present.
93
+ #
94
+ # @param [ActiveRecord::Relation] items
95
+ # @param [ActionController::Parameters] params The sort parameters
96
+ # @param [String, Array, nil] default_sort
97
+ # @return [Array(ActiveRecord::Relation, Symbol | nil, Boolean)] the sorted items, sorting name and sorting order
98
+ def perform_sort(items, params, default_sort)
99
+ sort_name = nil
100
+ reverse_order = params[:order] == "desc"
101
+
102
+ if params[:sort].present?
103
+ sort_name = params[:sort]
104
+ elsif default_sort.present? || const_defined?(:DEFAULT_SORT)
105
+ default_sort ||= const_get(:DEFAULT_SORT) if const_defined?(:DEFAULT_SORT)
106
+
107
+ sort_name, reverse_order = parse_default_sort(default_sort)
108
+ end
109
+
110
+ items = sort_items(items, sort_name, reverse_order, scope: params[:scope]) if sort_name.present?
111
+
112
+ [items, sort_name, reverse_order]
113
+ end
114
+
115
+ # Parses the default sort.
116
+ #
117
+ # @param [String, Array, nil] default_sort
118
+ # @return [Array(Symbol, Boolean)] the sorting scope and the sorting order
119
+ def parse_default_sort(default_sort)
120
+ sort_scope = nil
121
+ reverse_order = false
122
+
123
+ case default_sort
124
+ when Array
125
+ sort_scope = default_sort[0]
126
+ reverse_order = default_sort[1] == :desc
127
+ when String
128
+ sort_scope = default_sort
129
+ else
130
+ raise TypeError, "Invalid type found for the default sort, expected either Array or Symbol, got #{default_sort.class}"
131
+ end
132
+
133
+ [sort_scope, reverse_order]
134
+ end
135
+
136
+ # Sorts the items using the sorting scope and reversing the sort if required.
137
+ # Will prioitize using the explicit `_reversed` scope over reversing automatically.
138
+ #
139
+ # @param [ActiveRecord::Relation] items The items to sort
140
+ # @param [String] sort_name The sorting name
141
+ # @param [Boolean] reverse_order If true, will attempt to reverse the order of the sort
142
+ # @return [ActiveRecord::Relation] the sorted items
143
+ def sort_items(items, sort_name, reverse_order, scope: nil)
144
+ sort_scope = const_get(:SORT_SCOPE_MAPPINGS)[sort_name.to_sym]
145
+ reversed_sort_scope_sym = "#{sort_scope}_reversed".to_sym
146
+
147
+ if reverse_order && methods.include?(reversed_sort_scope_sym)
148
+ items.public_send(reversed_sort_scope_sym)
149
+ else
150
+ items = if scope.nil?
151
+ items.public_send(sort_scope)
152
+ else
153
+ items.public_send(sort_scope, scope.to_i)
154
+ end
155
+ items = items.reverse_order if reverse_order
156
+
157
+ items
158
+ end
159
+ end
160
+
161
+ # Scopes the user parameters to only contain filtering parameters.
162
+ #
163
+ # @param [ActionController::Parameters] params
164
+ # @return [ActionController::Parameters]
165
+ def filter_params(params)
166
+ return ActionController::Parameters.new.permit unless const_defined?(:FILTER_SCOPE_MAPPINGS)
167
+
168
+ filters_with_array_support = const_defined?(:ARRAY_FILTER_SCOPES) ? const_get(:ARRAY_FILTER_SCOPES)&.map { |x| { x => [] } } : []
169
+
170
+ params.permit(filter: const_get(:FILTER_SCOPE_MAPPINGS).keys.concat(filters_with_array_support))
171
+ end
172
+
173
+ # Scopes the user parameters to only contain sorting parameters.
174
+ #
175
+ # @param [ActionController::Parameters] params
176
+ # @return [ActionController::Parameters]
177
+ def sort_params(params)
178
+ params = params.permit(:sort, :order, :scope)
179
+ return ActionController::Parameters.new.permit unless sort_params_valid?(params)
180
+
181
+ params
182
+ end
183
+
184
+ # Validates the sorting parameters.
185
+ #
186
+ # @param [ActionController::Parameters] params
187
+ def sort_params_valid?(params)
188
+ const_defined?(:SORT_SCOPE_MAPPINGS) &&
189
+ params[:sort].present? && params[:sort].in?(const_get(:SORT_SCOPE_MAPPINGS).keys.map(&:to_s)) &&
190
+ (params[:order].blank? || params[:order].in?(%w[asc desc]))
191
+ end
192
+
193
+ # Scopes the user parameters to contain only other parameters (pagination parameters, :page, :per)
194
+ #
195
+ # @param [ActionController::Parameters] params
196
+ # @param [ActiveRecord::Relation] items The items to scope from the model, used to determine pagination max
197
+ # @return [ActionController::Parameters]
198
+ def other_params(params, items)
199
+ params = params.permit(:page, :per)
200
+
201
+ return ActionController::Parameters.new.permit unless other_params_valid?(params, items)
202
+
203
+ params
204
+ end
205
+
206
+ # Validates the other parameters.
207
+ #
208
+ # @param [ActionController::Parameters] params
209
+ # @param [ActiveRecord::Relation] items The items to scope from the model, used to determine pagination max
210
+ def other_params_valid?(params, items)
211
+ params[:per].blank? || params[:per].to_i.between?(1, items.max_per_page)
212
+ end
213
+
214
+ # Generates a valid hash of the sorting and filtering queries.
215
+ #
216
+ # @param [ActionController::Parameters] f_params The filter parameters
217
+ # @param [ActionController::Parameters] s_params The sort parameters
218
+ # @param [ActionController::Parameters] o_params The other parameters
219
+ # @return [Hash] the valid URL queries
220
+ def generate_url_queries(f_params, s_params, o_params)
221
+ queries = { "filter" => {} }
222
+
223
+ f_params["filter"].try(:each) { |k, v| queries["filter"][k] = v }
224
+
225
+ queries["sort"] = s_params["sort"] if s_params["sort"].present?
226
+ queries["order"] = s_params["order"] if s_params["order"].present?
227
+ queries["page"] = o_params["page"] if o_params["page"].present?
228
+ queries["per"] = o_params["per"] if o_params["per"].present?
229
+
230
+ queries
231
+ end
232
+ end
233
+
234
+ # Helper class for generating `WHERE` conditions
235
+ class WhereHelper
236
+ # Helper to find if a value exists in a string (case-insensitive)
237
+ #
238
+ # @param [String] column_key The column's key to search
239
+ # @param [String] value The value to look for (case-insensitive)
240
+ def self.contains(column_key, value)
241
+ ["strpos(LOWER(#{column_key}), ?) > 0", value.downcase.strip]
242
+ end
243
+ end
244
+
245
+ # Helper class for handling polymorphic associations
246
+ class PolymorphicHelper
247
+ # Helper to join the polymorphic associated tables.
248
+ #
249
+ # @param [ActiveRecord::Relation] relation The relation to apply the JOIN clause
250
+ # @param [Symbol, String] polymorphic_association The name of the polymorphic association
251
+ # @param [Array<Class>] models An array of the models that the polymorphic association applies to
252
+ # @return [ActiveRecord::Relation]
253
+ def self.joins(relation, polymorphic_association, models)
254
+ table_name = ActiveRecord::Base.connection.quote_table_name(relation.table.name)
255
+ polymorphic_association_id = ActiveRecord::Base.connection.quote_column_name("#{polymorphic_association}_id")
256
+ polymorphic_association_type = ActiveRecord::Base.connection.quote_column_name("#{polymorphic_association}_type")
257
+
258
+ models.each do |model|
259
+ associated_table = ActiveRecord::Base.connection.quote_table_name(model.table_name)
260
+ associated_type = model.name
261
+
262
+ join_query = ActiveRecord::Base.sanitize_sql_array([
263
+ "LEFT OUTER JOIN #{associated_table} ON #{associated_table}.id = #{table_name}.#{polymorphic_association_id} AND #{table_name}.#{polymorphic_association_type} = ?",
264
+ associated_type
265
+ ])
266
+
267
+ relation = relation.joins(join_query)
268
+ end
269
+
270
+ relation
271
+ end
272
+
273
+ # Coalesces the columns of a polymorphic association. Should be used in a relation that used the {#joins} helper.
274
+ #
275
+ # @param [Array<Class>] models An array of the models that the polymorphic association applies to
276
+ # @param [String] column The mutual column that exists in the models
277
+ # @return [String] the coalesce SQL function
278
+ def self.coalesce(models, column)
279
+ column = ActiveRecord::Base.connection.quote_column_name(column)
280
+
281
+ sql_columns = []
282
+
283
+ models.each do |model|
284
+ associated_table = ActiveRecord::Base.connection.quote_table_name(model.table_name)
285
+ sql_columns.push("#{associated_table}.#{column}")
286
+ end
287
+
288
+ "coalesce(#{sql_columns.join(', ')})"
289
+ end
290
+ end
291
+ end
292
+ end
@@ -0,0 +1,11 @@
1
+ module SnFilterable
2
+ class Railtie < ::Rails::Railtie
3
+ initializer "sn-filterable.view_helpers" do |app|
4
+ ActiveSupport.on_load(:action_view) do
5
+ # include Heroicon::Engine.helpers
6
+ include SnFilterable::FilteredHelper
7
+ end
8
+ # app.config.i18n.load_path += Dir[File.expand_path(File.join(File.dirname(__FILE__), '../locales', '*.yml')).to_s]
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SnFilterable
4
+ VERSION = "0.1.1"
5
+ end