sn_filterable 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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