active_admin_filter_extensions 1.0.0
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/.github/workflows/ci.yml +42 -0
- data/.gitignore +7 -0
- data/.rspec +3 -0
- data/Gemfile +22 -0
- data/LICENSE +21 -0
- data/README.md +357 -0
- data/Rakefile +18 -0
- data/active_admin_filter_extensions.gemspec +29 -0
- data/config/locales/en.yml +10 -0
- data/lib/active_admin_filter_extensions/data_access.rb +22 -0
- data/lib/active_admin_filter_extensions/filter_defaults.rb +327 -0
- data/lib/active_admin_filter_extensions/filter_options.rb +70 -0
- data/lib/active_admin_filter_extensions/filters_form.rb +47 -0
- data/lib/active_admin_filter_extensions/notice.rb +38 -0
- data/lib/active_admin_filter_extensions/policies.rb +539 -0
- data/lib/active_admin_filter_extensions/redirect.rb +82 -0
- data/lib/active_admin_filter_extensions/required_filters.rb +83 -0
- data/lib/active_admin_filter_extensions/search_keys.rb +71 -0
- data/lib/active_admin_filter_extensions/version.rb +5 -0
- data/lib/active_admin_filter_extensions.rb +46 -0
- data/spec/data_access_spec.rb +252 -0
- data/spec/integration/extensions_container_spec.rb +38 -0
- data/spec/integration/filter_defaults_spec.rb +229 -0
- data/spec/integration/numeric_builtins_spec.rb +104 -0
- data/spec/integration/on_invalid_spec.rb +174 -0
- data/spec/integration/policies_spec.rb +204 -0
- data/spec/integration/redirect_spec.rb +204 -0
- data/spec/integration/require_any_filter_spec.rb +132 -0
- data/spec/integration/required_spec.rb +155 -0
- data/spec/integration/structural_keys_spec.rb +77 -0
- data/spec/integration/window_spec.rb +208 -0
- data/spec/integration/with_spec.rb +175 -0
- data/spec/rails_helper.rb +62 -0
- data/spec/redirect_install_spec.rb +27 -0
- data/spec/spec_helper.rb +68 -0
- data/spec/support/capybara_driver.rb +12 -0
- data/spec/support/rails_template.rb +637 -0
- data/tasks/test.rake +32 -0
- metadata +103 -0
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveAdminFilterExtensions
|
|
4
|
+
# Everything `filter ..., default:` means: which requests the declared defaults apply to, what
|
|
5
|
+
# they come to on this one, and which Ransack key each value lands under.
|
|
6
|
+
#
|
|
7
|
+
# Included rather than prepended - none of it replaces anything Active Admin defines, and a
|
|
8
|
+
# resource is meant to be able to override the seams here in its own `controller do` block.
|
|
9
|
+
# The one method that does replace Active Admin's is in DataAccess.
|
|
10
|
+
module FilterDefaults
|
|
11
|
+
# Ransack reads more than conditions out of q: `m` switches the combinator to OR - turning
|
|
12
|
+
# a required, capped window into `window OR anything` - and `g` nests whole condition
|
|
13
|
+
# groups no value policy ever saw. No Active Admin filter form submits them; where this gem
|
|
14
|
+
# guards the filters they are a bypass, not an answer, and they are stripped before anything
|
|
15
|
+
# reads the request. On a resource that declares nothing of this gem's there is no policy to
|
|
16
|
+
# bypass, and `q[g]` is the app's own standard OR workaround - see #strip_ransack_structure?.
|
|
17
|
+
RANSACK_STRUCTURE = %w[m g c conditions groupings combinator].freeze
|
|
18
|
+
# Sorting is not a condition: it reaches Ransack untouched, and it is not an answer to the
|
|
19
|
+
# question the defaults exist to answer.
|
|
20
|
+
RANSACK_SORT = %w[s sorts].freeze
|
|
21
|
+
|
|
22
|
+
protected
|
|
23
|
+
|
|
24
|
+
# The filter values the collection is searched with. Override to change what the index
|
|
25
|
+
# filters on without reaching into `params`.
|
|
26
|
+
#
|
|
27
|
+
# Whatever the request asked for wins over a default, so that an override of
|
|
28
|
+
# #filter_defaults_apply? which lets defaults through on a partly filtered request keeps
|
|
29
|
+
# the values that were actually asked for.
|
|
30
|
+
#
|
|
31
|
+
# @return [Hash, ActionController::Parameters] values passed to Ransack
|
|
32
|
+
def filtering_params
|
|
33
|
+
requested = requested_filtering_params
|
|
34
|
+
# Materialized on every path: computing it is also what validates, loudly, that every
|
|
35
|
+
# required: filter has a default: to fall back on.
|
|
36
|
+
required_filter_default_values
|
|
37
|
+
|
|
38
|
+
defaults =
|
|
39
|
+
if filter_defaults_apply?
|
|
40
|
+
filter_default_values
|
|
41
|
+
else
|
|
42
|
+
# A filter marked required: applies when ALL its own keys are absent. A request that
|
|
43
|
+
# touched any of them - a half-range included - said what it wants there; completing
|
|
44
|
+
# it from the default could manufacture a pair the admin never meant (an inverted
|
|
45
|
+
# range, say). What a half-open range may not mean is max_range:'s business.
|
|
46
|
+
required_filter_defaults_absent_from(requested || {})
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
defaults = defaults.merge(reset_filter_default_values)
|
|
50
|
+
# A refill honors with: atomicity: pulling one member of a group pulls its partners'
|
|
51
|
+
# defaults along (the materialization check guarantees they exist) - the merge must
|
|
52
|
+
# not hand Ransack the half-group the enforcement just promised away.
|
|
53
|
+
defaults = complete_with_groups(defaults)
|
|
54
|
+
return requested || {} if defaults.blank?
|
|
55
|
+
|
|
56
|
+
defaults.merge(requested || {})
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def complete_with_groups(defaults)
|
|
60
|
+
with_groups.each do |keys|
|
|
61
|
+
next unless keys.any? { |key| defaults.key?(key) }
|
|
62
|
+
|
|
63
|
+
# A member that opted out this request leaves the group uncompletable, and half a group
|
|
64
|
+
# is the search the all-or-none contract refuses - so the group imposes nothing.
|
|
65
|
+
next defaults = defaults.except(*keys) if keys.any? { |key| nil_default_filter_keys.include?(key) }
|
|
66
|
+
|
|
67
|
+
defaults = filter_default_values.slice(*keys).merge(defaults)
|
|
68
|
+
end
|
|
69
|
+
defaults
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# The keys of filters that declare a default: which resolved to nil for this request. A nil
|
|
73
|
+
# Proc result imposes nothing - that is how a per-admin default opts out - so it is not the
|
|
74
|
+
# missing declaration the with: completeness check refuses.
|
|
75
|
+
def nil_default_filter_keys
|
|
76
|
+
@nil_default_filter_keys ||= []
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# The defaults of keys a policy just reset - refilled whatever filter_defaults_apply?
|
|
80
|
+
# says, because the reset itself is what emptied them.
|
|
81
|
+
def reset_filter_default_values
|
|
82
|
+
return {} if filter_policy_reset_keys.empty?
|
|
83
|
+
|
|
84
|
+
# By attribute, not by exact key: a sibling-predicate reset (position_gt) must still
|
|
85
|
+
# refill the filter's own declared default (position_eq).
|
|
86
|
+
bases = filter_policy_reset_keys.map { |key| strip_ransack_predicate(key) }
|
|
87
|
+
filter_default_values.select { |key, _| bases.include?(strip_ransack_predicate(key)) }
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Whether any default actually reaches this request's search - what the notice announces.
|
|
91
|
+
# Wider than #filter_defaults_apply?: a required filter engages on a partly-filtered
|
|
92
|
+
# request too, as long as the request left its keys blank.
|
|
93
|
+
def filter_defaults_engaged?
|
|
94
|
+
requested = requested_filtering_params || {}
|
|
95
|
+
if filter_defaults_apply?
|
|
96
|
+
# At least one default must actually reach the search: a widened apply? beside a
|
|
97
|
+
# request that answered every defaulted key itself engaged nothing. Through
|
|
98
|
+
# #complete_with_groups, which is what decides that on the merge path - a with: group
|
|
99
|
+
# one member opted out of imposes nothing, and announcing it would describe a default
|
|
100
|
+
# slice over the unfiltered collection.
|
|
101
|
+
return complete_with_groups(filter_default_values).keys.any? { |key| requested[key].nil? }
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
required_filter_defaults_absent_from(requested).any?
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Whether the request is one the declared defaults should apply to. Override to widen it -
|
|
108
|
+
# a resource that always carries its customer in `q`, say, still wants its defaults on the
|
|
109
|
+
# first visit:
|
|
110
|
+
#
|
|
111
|
+
# def filter_defaults_apply?
|
|
112
|
+
# super || params[:q].keys == %w[customer_id_eq]
|
|
113
|
+
# end
|
|
114
|
+
#
|
|
115
|
+
# `commit` marks a submission of the filters form: submitting it with every field blank
|
|
116
|
+
# sends no `q` at all, because the form disables empty fields on submit, so `commit` is
|
|
117
|
+
# what tells "show me everything" apart from a first visit. Clearing the filters drops
|
|
118
|
+
# `commit` along with `q`, so Clear Filters returns the page to its declared defaults.
|
|
119
|
+
# Judged on the NORMALIZED request: a q holding nothing but blanks or structural keys is
|
|
120
|
+
# a bare request on every format - the CSV gets no canonicalizing hop to say otherwise.
|
|
121
|
+
def filter_defaults_apply?
|
|
122
|
+
(requested_filtering_params || {}).except(*RANSACK_SORT).blank? && params[:commit].blank?
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# @return [Hash] the search values declared with `filter ..., default:`
|
|
126
|
+
def filter_default_values
|
|
127
|
+
@filter_default_values ||= visible_filters.each_with_object({}) do |(attribute, options), result|
|
|
128
|
+
next unless options.key?(:default)
|
|
129
|
+
|
|
130
|
+
before = result.keys
|
|
131
|
+
add_filter_default_value(result, attribute, options)
|
|
132
|
+
added = result.keys - before
|
|
133
|
+
(@filter_default_keys ||= {})[attribute] = added
|
|
134
|
+
nil_default_filter_keys.concat(policy_search_keys(attribute, options)) if added.empty?
|
|
135
|
+
# Value policies first: the window must be judged on what the sanitizer stored.
|
|
136
|
+
validate_default_against_value_policies(attribute, options, result, result.keys - before)
|
|
137
|
+
validate_default_against_policies(attribute, options, result)
|
|
138
|
+
end.tap { |defaults| validate_defaults_against_with_groups(defaults) }
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# A with: group is all-or-none in the fallback too: defaults answering part of a group
|
|
142
|
+
# would execute the very half-group the enforcement refuses - on a bare visit, where no
|
|
143
|
+
# enforcement even runs.
|
|
144
|
+
def validate_defaults_against_with_groups(defaults)
|
|
145
|
+
with_groups.each do |keys|
|
|
146
|
+
# By member, so a half-open Range default - which fills only the end it has - answers
|
|
147
|
+
# its two-ended member rather than reading as half a group.
|
|
148
|
+
members = with_group_members(keys)
|
|
149
|
+
answered = members.select do |member|
|
|
150
|
+
member.any? { |key| defaults.key?(key) || nil_default_filter_keys.include?(key) }
|
|
151
|
+
end
|
|
152
|
+
next if answered.empty? || answered.size == members.size
|
|
153
|
+
|
|
154
|
+
raise ArgumentError,
|
|
155
|
+
"filters #{keys.join(', ')} are declared with: one another, but only " \
|
|
156
|
+
"#{answered.flatten.join(', ')} declare defaults - the fallback must answer the " \
|
|
157
|
+
"whole group or none of it"
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# A default the filter's own window policy would refuse is a configuration error: on the
|
|
162
|
+
# non-redirected formats it would run the over-cap scan silently, forever.
|
|
163
|
+
def validate_default_against_policies(attribute, options, values_by_key)
|
|
164
|
+
return unless options[:max_range] || options[:min]
|
|
165
|
+
|
|
166
|
+
# Looked up by the derived keys, not insertion order: a beginless default fills only the
|
|
167
|
+
# lteq slot, and judged as a lower bound it would pass as a future-open window.
|
|
168
|
+
gteq_key, lteq_key = filter_search_keys(attribute, options)
|
|
169
|
+
from, to = values_by_key.values_at(gteq_key, lteq_key)
|
|
170
|
+
return if from.nil? && to.nil?
|
|
171
|
+
|
|
172
|
+
violation = window_violation(options, from, to)
|
|
173
|
+
return unless violation
|
|
174
|
+
|
|
175
|
+
raise ArgumentError,
|
|
176
|
+
"filter :#{attribute} declares a default that violates its own window policy " "(#{violation.first}) - the fallback must satisfy max_range:/min:"
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# A default the filter's own validate: would refuse is the same configuration error: it
|
|
180
|
+
# refills on every bare visit, fails, resets, and refills - a permanent self-inflicted
|
|
181
|
+
# error banner. Judged - and stored - the way the URL will say it: sanitized, as a
|
|
182
|
+
# String, because that is the value every next request actually carries.
|
|
183
|
+
def validate_default_against_value_policies(attribute, options, result, keys)
|
|
184
|
+
sanitizer = options[:sanitize]
|
|
185
|
+
validator = options[:validate]
|
|
186
|
+
return unless sanitizer || validator
|
|
187
|
+
|
|
188
|
+
keys.each do |key|
|
|
189
|
+
value = result[key]
|
|
190
|
+
next if value.nil?
|
|
191
|
+
|
|
192
|
+
# Judged the way the URL will say it: a scalar as the String it becomes, an Array
|
|
193
|
+
# element by element - the shape a multi-valued input submits.
|
|
194
|
+
if sanitizer
|
|
195
|
+
resolved = Policies::Sanitizers.resolve(sanitizer)
|
|
196
|
+
result[key] = sanitize_default_value(attribute, resolved, value)
|
|
197
|
+
end
|
|
198
|
+
next unless validator
|
|
199
|
+
|
|
200
|
+
resolved_validator = Policies::Validators.resolve(validator)
|
|
201
|
+
next unless default_value_strings(result[key])
|
|
202
|
+
.any? { |string| ::MethodOrProcHelper.render_in_context(self, resolved_validator, string) }
|
|
203
|
+
|
|
204
|
+
raise ArgumentError,
|
|
205
|
+
"filter :#{attribute} declares a default that violates its own validate: " \
|
|
206
|
+
"policy - the fallback must be a value the filter accepts"
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# The values a default actually submits: an Array element by element, anything else as the
|
|
211
|
+
# one String the URL will say. Blank ones are dropped the way #requested_filtering_params
|
|
212
|
+
# drops them on the way back in - [nil, "published"] rides the URL as
|
|
213
|
+
# ?q[k][]=&q[k][]=published and searches the second alone, and a blank scalar cannot ride
|
|
214
|
+
# it at all - so judging either refuses a value nothing ever searches.
|
|
215
|
+
#
|
|
216
|
+
# Array.wrap, not Array(): it asks for to_ary, so a Hash default stays one value to judge
|
|
217
|
+
# instead of being splatted into its pairs.
|
|
218
|
+
def default_value_strings(value)
|
|
219
|
+
Array.wrap(value).map(&:to_s).reject(&:blank?)
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def sanitize_default_value(attribute, sanitizer, value)
|
|
223
|
+
return value.map { |element| sanitize_default_value(attribute, sanitizer, element) } if value.is_a?(Array)
|
|
224
|
+
return value if value.is_a?(Enumerable)
|
|
225
|
+
|
|
226
|
+
sanitized = ::MethodOrProcHelper.render_in_context(self, sanitizer, value.to_s.dup).to_s
|
|
227
|
+
# The same fixpoint check submitted values face: without it the CSV searches the
|
|
228
|
+
# once-sanitized value while the HTML converges on another, and nobody raises.
|
|
229
|
+
if ::MethodOrProcHelper.render_in_context(self, sanitizer, sanitized.dup).to_s != sanitized
|
|
230
|
+
raise ArgumentError,
|
|
231
|
+
"filter :#{attribute}'s sanitize: is not idempotent " \
|
|
232
|
+
"(#{value.inspect} -> #{sanitized.inspect} -> keeps changing) - " \
|
|
233
|
+
"a default must sanitize to a fixpoint"
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
sanitized
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
private
|
|
240
|
+
|
|
241
|
+
def requested_filtering_params
|
|
242
|
+
requested = params[:q]
|
|
243
|
+
requested = requested.to_unsafe_h if requested.respond_to?(:to_unsafe_h)
|
|
244
|
+
# `?q=` parses to an empty String - blank, so the defaults apply, but not a Hash, so it
|
|
245
|
+
# must not reach the merge. Anything else non-Hash is junk Ransack would ignore anyway.
|
|
246
|
+
return nil unless requested.is_a?(Hash)
|
|
247
|
+
|
|
248
|
+
# A blank value is no answer: Ransack strips it before searching, so letting it clobber
|
|
249
|
+
# a default in the merge - or count as "this key was answered" for a required: window -
|
|
250
|
+
# turns ?q[k]= into a full-table scan wearing an answered-looking URL. An Array answers
|
|
251
|
+
# with what is left of it once ITS blanks are gone: [""] is as empty as "".
|
|
252
|
+
requested = requested.transform_values do |value|
|
|
253
|
+
value.is_a?(Array) ? value.reject { |element| element.blank? && element != false } : value
|
|
254
|
+
end
|
|
255
|
+
strip_structure = strip_ransack_structure?
|
|
256
|
+
requested.reject do |key, value|
|
|
257
|
+
(strip_structure && RANSACK_STRUCTURE.include?(key.to_s)) || (value.blank? && value != false)
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
# Whether this resource declares anything of this gem's. Only then is a structural key a
|
|
262
|
+
# bypass of something; on every other resource it is the app's own search and stripping it
|
|
263
|
+
# silently widens the export to the whole table.
|
|
264
|
+
def strip_ransack_structure?
|
|
265
|
+
return @strip_ransack_structure if defined?(@strip_ransack_structure)
|
|
266
|
+
|
|
267
|
+
@strip_ransack_structure =
|
|
268
|
+
active_admin_config.require_any_filter_notice.present? ||
|
|
269
|
+
active_admin_config.default_filters_notice.present? ||
|
|
270
|
+
visible_filters.any? { |_attribute, options| options.key?(:extensions) }
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
# A default is a value, and where that value goes is the input's business - see SearchKeys.
|
|
274
|
+
#
|
|
275
|
+
# filter :status, as: :select, default: "active"
|
|
276
|
+
# filter :author, as: :check_boxes, default: [1, 2]
|
|
277
|
+
# filter :created_at, as: :date_range, default: -> { 1.week.ago.. }
|
|
278
|
+
#
|
|
279
|
+
# A Hash still says the predicates outright, for when the input's own is not the one you
|
|
280
|
+
# want (`default: { eq: "acme" }` on a string filter that would otherwise search `_cont`).
|
|
281
|
+
#
|
|
282
|
+
# Only a Proc is called - unlike `:if` and `:unless`, a Symbol here is a value, not a
|
|
283
|
+
# method to send.
|
|
284
|
+
def add_filter_default_value(result, attribute, options)
|
|
285
|
+
default = options[:default]
|
|
286
|
+
default = instance_exec(&default) if default.is_a?(Proc)
|
|
287
|
+
return if default.nil?
|
|
288
|
+
|
|
289
|
+
if default.is_a?(Hash)
|
|
290
|
+
default.each do |predicate, value|
|
|
291
|
+
value = instance_exec(&value) if value.is_a?(Proc)
|
|
292
|
+
result["#{attribute}_#{predicate}"] = value unless value.nil?
|
|
293
|
+
end
|
|
294
|
+
else
|
|
295
|
+
assign_derived_filter_default(result, attribute, options, default)
|
|
296
|
+
end
|
|
297
|
+
end
|
|
298
|
+
# A Range fills a two-ended input, one bound per end, and an endless or beginless one fills
|
|
299
|
+
# only the end it has. Anything else is a single value for a single-ended input.
|
|
300
|
+
def assign_derived_filter_default(result, attribute, options, default)
|
|
301
|
+
names = filter_search_keys(attribute, options)
|
|
302
|
+
|
|
303
|
+
if default.is_a?(Range)
|
|
304
|
+
unless names.size == 2
|
|
305
|
+
raise_filter_default_error(attribute, "a Range needs an input with two ends, and this one submits #{names.join(' and ')}")
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
# The input submits gteq and lteq, so an excluded end has no spelling here: stored as
|
|
309
|
+
# lteq it would search the very bound the Range left out, quietly and in every copied
|
|
310
|
+
# URL. An endless exclusive Range has no end to exclude and is fine.
|
|
311
|
+
if default.exclude_end? && !default.end.nil?
|
|
312
|
+
raise_filter_default_error(attribute, "an exclusive Range has no gteq/lteq spelling, so its end would be searched after all - use an inclusive range, or name the predicates with a Hash")
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
result[names.first] = default.begin unless default.begin.nil?
|
|
316
|
+
result[names.last] = default.end unless default.end.nil?
|
|
317
|
+
else
|
|
318
|
+
unless names.size == 1
|
|
319
|
+
raise_filter_default_error(attribute, "this input submits #{names.join(' and ')}, so a single value cannot say which to fill - give a Range, or name the predicates with a Hash")
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
result[names.first] = default
|
|
323
|
+
end
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
end
|
|
327
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveAdminFilterExtensions
|
|
4
|
+
# The boundary where Active Admin's filter options meet this gem's: which filters are
|
|
5
|
+
# visible on this request, and the one `extensions:` container everything of the gem's lives
|
|
6
|
+
# under - so the form never has to know the keys one by one and their names can never
|
|
7
|
+
# collide with Formtastic's own (required:, most notably).
|
|
8
|
+
module FilterOptions
|
|
9
|
+
# Read here and never handed to an input: `:policy` holds everything of this gem's, and
|
|
10
|
+
# `:if` / `:unless` are Active Admin's but are resolved by #visible_filters before the
|
|
11
|
+
# form is given anything.
|
|
12
|
+
NOT_FOR_INPUT = %i[extensions if unless].freeze
|
|
13
|
+
EXTENSION_KEYS = %i[default required with sanitize validate on_invalid max_range min message].freeze
|
|
14
|
+
# required: and min: are documented Formtastic input options in their own right. The gem
|
|
15
|
+
# answers to them only INSIDE extensions:, so flat they are the host's: no stray-key raise,
|
|
16
|
+
# no policy, straight through to the input.
|
|
17
|
+
HOST_INPUT_KEYS = %i[required min].freeze
|
|
18
|
+
STRAY_KEYS = (EXTENSION_KEYS - HOST_INPUT_KEYS).freeze
|
|
19
|
+
# Where #merge_filter_extensions parks the host's own required:/min:, out of reach of the
|
|
20
|
+
# gem's flat reads until #input_options hands them back to the input.
|
|
21
|
+
HOST_INPUT_OPTIONS = :extensions_host_input_options
|
|
22
|
+
|
|
23
|
+
# What the form may see: the gem's container and conditions out, the keys the container
|
|
24
|
+
# itself declared out, and the host's own options back in.
|
|
25
|
+
def self.input_options(options)
|
|
26
|
+
options.except(*NOT_FOR_INPUT, HOST_INPUT_OPTIONS,
|
|
27
|
+
*(options[:extensions] || {}).keys.map(&:to_sym))
|
|
28
|
+
.merge(options[HOST_INPUT_OPTIONS] || {})
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
protected
|
|
32
|
+
|
|
33
|
+
# The filters of this resource that `:if` and `:unless` allow, which is both the set the
|
|
34
|
+
# filters form renders and the set that can impose a default - a filter that is not
|
|
35
|
+
# rendered does not filter the collection behind the admin's back.
|
|
36
|
+
#
|
|
37
|
+
# @return [Hash] filter attribute => filter options
|
|
38
|
+
def visible_filters
|
|
39
|
+
@visible_filters ||= active_admin_config.filters.reject do |_attribute, options|
|
|
40
|
+
(options.key?(:if) && !::MethodOrProcHelper.render_in_context(self, options[:if])) ||
|
|
41
|
+
(options.key?(:unless) && ::MethodOrProcHelper.render_in_context(self, options[:unless]))
|
|
42
|
+
end.to_h { |attribute, options| [attribute, merge_filter_extensions(attribute, options)] }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Everything of the gem's lives under ONE filter option - extensions: - so the form never has
|
|
46
|
+
# to know the keys one by one and their names can never collide with Formtastic's own
|
|
47
|
+
# (required:, most notably). Internally the sub-hash merges flat; a gem key left outside
|
|
48
|
+
# extensions: raises rather than silently meaning something else to Formtastic.
|
|
49
|
+
def merge_filter_extensions(attribute, options)
|
|
50
|
+
stray = options.keys.map(&:to_sym) & STRAY_KEYS
|
|
51
|
+
if stray.any?
|
|
52
|
+
raise ArgumentError, "filter :#{attribute}: #{stray.map(&:inspect).join(', ')} " "belong inside extensions: { ... }"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Symbolized, because the unknown-key check below symbolizes too: a string-keyed container
|
|
56
|
+
# otherwise passed it and then merged under a key the gem never reads.
|
|
57
|
+
extensions = (options[:extensions] || {}).transform_keys(&:to_sym)
|
|
58
|
+
unknown = extensions.keys.map(&:to_sym) - EXTENSION_KEYS
|
|
59
|
+
if unknown.any?
|
|
60
|
+
raise ArgumentError, "filter :#{attribute}: unknown extensions: #{unknown.map(&:inspect).join(', ')} - " "the gem ships #{EXTENSION_KEYS.map(&:inspect).join(', ')}"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# The host's own required:/min: are parked rather than merged: left in, the gem would read
|
|
64
|
+
# a flat min: as a window policy on an input that submits one key.
|
|
65
|
+
host = options.slice(*HOST_INPUT_KEYS)
|
|
66
|
+
merged = options.except(*HOST_INPUT_KEYS).merge(extensions)
|
|
67
|
+
host.any? ? merged.merge(HOST_INPUT_OPTIONS => host) : merged
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveAdminFilterExtensions
|
|
4
|
+
# The filters sidebar hands the form the filters that `:if` and `:unless` allow, so the same
|
|
5
|
+
# set is rendered and consulted for defaults, and the conditions are resolved once.
|
|
6
|
+
module ResourceExtension
|
|
7
|
+
private
|
|
8
|
+
|
|
9
|
+
def filters_sidebar_section
|
|
10
|
+
ActiveAdmin::SidebarSection.new :filters, only: :index, if: -> { active_admin_config.filters.any? } do
|
|
11
|
+
# Passed positionally rather than splatted: the section is shown when the resource has
|
|
12
|
+
# any filter at all, and `**{}` would drop the argument entirely once `:if` and
|
|
13
|
+
# `:unless` have denied every one of them.
|
|
14
|
+
active_admin_filters_form_for assigns[:search], visible_filters
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# The form renders what it is handed: `:if` and `:unless` were already resolved by
|
|
20
|
+
# #visible_filters, and the policy keys - merged flat into these options by
|
|
21
|
+
# #merge_filter_extensions - are not Formtastic's business. required: in particular is also a
|
|
22
|
+
# Formtastic option: leaked, it would mark the sidebar input HTML-required.
|
|
23
|
+
#
|
|
24
|
+
# The options are cleaned before `super` sees them rather than editing the loop inside the
|
|
25
|
+
# method, so that this gem carries no copy of a method body that belongs to Active Admin.
|
|
26
|
+
module ViewHelper
|
|
27
|
+
def active_admin_filters_form_for(search, filters, options = {})
|
|
28
|
+
super(search, filters.transform_values { |opts| FilterOptions.input_options(opts) }, options)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# The Current Filters panel is gated on `params[:q]`, which stays empty when the values came
|
|
33
|
+
# from `filter ..., default:` rather than from the request.
|
|
34
|
+
module ActiveSidebar
|
|
35
|
+
protected
|
|
36
|
+
|
|
37
|
+
def sidebar_options
|
|
38
|
+
{
|
|
39
|
+
only: :index,
|
|
40
|
+
if: -> {
|
|
41
|
+
active_admin_config.current_filters_enabled? &&
|
|
42
|
+
(params[:q] || params[:scope] || filter_default_values.present?)
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveAdminFilterExtensions
|
|
4
|
+
# The flash that tells the admin why the index shows a slice:
|
|
5
|
+
#
|
|
6
|
+
# ActiveAdmin.register Export do
|
|
7
|
+
# default_filters_notice "Records for the last month are displayed by default"
|
|
8
|
+
# filter :created_at, as: :date_range, default: { gteq: -> { 1.month.ago } }
|
|
9
|
+
# end
|
|
10
|
+
#
|
|
11
|
+
# An index that quietly shows a slice of the table owes the admin a word about why. The
|
|
12
|
+
# message is only flashed when the defaults actually took effect, so it stays quiet once the
|
|
13
|
+
# admin has filtered for themselves.
|
|
14
|
+
module Notice
|
|
15
|
+
def default_filters_notice(message, flash_key: :notice)
|
|
16
|
+
# Declared on the resource rather than closed over, so that the redirect - which answers
|
|
17
|
+
# the request the notice belongs on with a 302 - can find it and carry it across.
|
|
18
|
+
config.default_filters_notice = message
|
|
19
|
+
config.default_filters_notice_flash_key = flash_key
|
|
20
|
+
|
|
21
|
+
before_action only: :index do
|
|
22
|
+
# Engaged, not merely declared: the notice must not greet an admin who filtered for
|
|
23
|
+
# themselves - but must speak when a required: filter just imposed its window on a
|
|
24
|
+
# partly-filtered request.
|
|
25
|
+
next unless filter_defaults_engaged?
|
|
26
|
+
# A policy just rejected a value: the error already explains why the page shows the
|
|
27
|
+
# default slice - the notice on top of it would be noise.
|
|
28
|
+
next if filter_policy_errors.present?
|
|
29
|
+
|
|
30
|
+
flash.now[flash_key] = ::MethodOrProcHelper.render_in_context(self, message)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
module NoticeResource
|
|
36
|
+
attr_accessor :default_filters_notice, :default_filters_notice_flash_key
|
|
37
|
+
end
|
|
38
|
+
end
|