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.
- checksums.yaml +7 -0
- data/README.md +199 -0
- data/Rakefile +12 -0
- data/app/assets/javascripts/sn_filtering.js +63 -0
- data/app/components/sn_filterable/base_components/base_component.rb +26 -0
- data/app/components/sn_filterable/base_components/button_component.rb +71 -0
- data/app/components/sn_filterable/category_component.html.erb +11 -0
- data/app/components/sn_filterable/category_component.rb +18 -0
- data/app/components/sn_filterable/chips_component.html.erb +37 -0
- data/app/components/sn_filterable/chips_component.rb +78 -0
- data/app/components/sn_filterable/filter_button_component.html.erb +1 -0
- data/app/components/sn_filterable/filter_button_component.rb +29 -0
- data/app/components/sn_filterable/filter_category_component.html.erb +32 -0
- data/app/components/sn_filterable/filter_category_component.rb +30 -0
- data/app/components/sn_filterable/main_component.html.erb +97 -0
- data/app/components/sn_filterable/main_component.rb +39 -0
- data/app/components/sn_filterable/search_component.html.erb +16 -0
- data/app/components/sn_filterable/search_component.rb +13 -0
- data/lib/models/filtered.rb +171 -0
- data/lib/sn_filterable/engine.rb +10 -0
- data/lib/sn_filterable/filterable.rb +292 -0
- data/lib/sn_filterable/railtie.rb +11 -0
- data/lib/sn_filterable/version.rb +5 -0
- data/lib/sn_filterable.rb +182 -0
- metadata +294 -0
@@ -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,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
|