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.
Files changed (40) hide show
  1. checksums.yaml +7 -0
  2. data/.github/workflows/ci.yml +42 -0
  3. data/.gitignore +7 -0
  4. data/.rspec +3 -0
  5. data/Gemfile +22 -0
  6. data/LICENSE +21 -0
  7. data/README.md +357 -0
  8. data/Rakefile +18 -0
  9. data/active_admin_filter_extensions.gemspec +29 -0
  10. data/config/locales/en.yml +10 -0
  11. data/lib/active_admin_filter_extensions/data_access.rb +22 -0
  12. data/lib/active_admin_filter_extensions/filter_defaults.rb +327 -0
  13. data/lib/active_admin_filter_extensions/filter_options.rb +70 -0
  14. data/lib/active_admin_filter_extensions/filters_form.rb +47 -0
  15. data/lib/active_admin_filter_extensions/notice.rb +38 -0
  16. data/lib/active_admin_filter_extensions/policies.rb +539 -0
  17. data/lib/active_admin_filter_extensions/redirect.rb +82 -0
  18. data/lib/active_admin_filter_extensions/required_filters.rb +83 -0
  19. data/lib/active_admin_filter_extensions/search_keys.rb +71 -0
  20. data/lib/active_admin_filter_extensions/version.rb +5 -0
  21. data/lib/active_admin_filter_extensions.rb +46 -0
  22. data/spec/data_access_spec.rb +252 -0
  23. data/spec/integration/extensions_container_spec.rb +38 -0
  24. data/spec/integration/filter_defaults_spec.rb +229 -0
  25. data/spec/integration/numeric_builtins_spec.rb +104 -0
  26. data/spec/integration/on_invalid_spec.rb +174 -0
  27. data/spec/integration/policies_spec.rb +204 -0
  28. data/spec/integration/redirect_spec.rb +204 -0
  29. data/spec/integration/require_any_filter_spec.rb +132 -0
  30. data/spec/integration/required_spec.rb +155 -0
  31. data/spec/integration/structural_keys_spec.rb +77 -0
  32. data/spec/integration/window_spec.rb +208 -0
  33. data/spec/integration/with_spec.rb +175 -0
  34. data/spec/rails_helper.rb +62 -0
  35. data/spec/redirect_install_spec.rb +27 -0
  36. data/spec/spec_helper.rb +68 -0
  37. data/spec/support/capybara_driver.rb +12 -0
  38. data/spec/support/rails_template.rb +637 -0
  39. data/tasks/test.rake +32 -0
  40. metadata +103 -0
@@ -0,0 +1,539 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ipaddr"
4
+
5
+ module ActiveAdminFilterExtensions
6
+ # Value policies on a filter: what the home-grown index before_actions did, as options.
7
+ #
8
+ # filter :ip_contains, as: :string,
9
+ # extensions: { sanitize: ->(value) { value.sub(%r{/+\z}, "") },
10
+ # validate: :ip }
11
+ #
12
+ # filter :title_cont, as: :string,
13
+ # extensions: { default: "acme",
14
+ # validate: ->(value) { "Title filter must not contain digits." if value.match?(/\d/) } }
15
+ #
16
+ # `sanitize:` rewrites the value in place before anything reads it - before validation and,
17
+ # in the :redirect mode, before the redirect decision, so the URL carries the value that was
18
+ # actually searched. `validate:` is a callable returning nil for a good value or a message
19
+ # (a String, or a Symbol naming an i18n key under active_admin_filter_extensions.*) - or the
20
+ # name of a built-in (validate: :ip, sanitize: :digits). Callables are instance_exec'd against the controller, like `default:`.
21
+ #
22
+ # On a violation the policy is :reset, the shape every app of this size hand-rolled: the
23
+ # offending key is deleted from `q`, the message lands in flash[:error], and the filter's
24
+ # declared `default:` - if any - refills through the ordinary merge. The defaults notice
25
+ # stays quiet on that render: the error already explains why the page shows the default
26
+ # slice.
27
+ module Policies
28
+ # A callable is itself, a Symbol names a built-in, anything else is a loud typo.
29
+ def self.resolve_built_in(kind, built_in, value)
30
+ return value if value.respond_to?(:call)
31
+
32
+ built_in.fetch(value) do
33
+ raise ArgumentError, "unknown built-in #{kind} #{value.inspect} - " \
34
+ "the gem ships #{built_in.keys.map(&:inspect).join(', ')}"
35
+ end
36
+ end
37
+
38
+ # Validators the gem ships. Each takes the sanitized String value and answers nil (good)
39
+ # or an i18n key Symbol.
40
+ module Validators
41
+ IP = lambda do |value|
42
+ IPAddr.new(value.to_s)
43
+ nil
44
+ rescue IPAddr::Error
45
+ :invalid
46
+ end
47
+
48
+ # An integer literal that fits a PG int4 - what every integer column cast accepts.
49
+ INT4 = ((-2**31)..(2**31 - 1))
50
+ INTEGER = lambda do |value|
51
+ :invalid unless value.to_s.match?(/\A-?\d+\z/) && INT4.cover?(value.to_i)
52
+ end
53
+
54
+ BUILT_IN = { ip: IP, integer: INTEGER }.freeze
55
+
56
+ def self.resolve(validator)
57
+ Policies.resolve_built_in("validator", BUILT_IN, validator)
58
+ end
59
+ end
60
+
61
+ # Sanitizers the gem ships. Each takes the raw String value and answers the value to
62
+ # actually search - idempotent, as every sanitizer must be.
63
+ module Sanitizers
64
+ DIGITS = ->(value) { value.to_s.gsub(/\D/, "") }
65
+
66
+ BUILT_IN = { digits: DIGITS }.freeze
67
+
68
+ def self.resolve(sanitizer)
69
+ Policies.resolve_built_in("sanitizer", BUILT_IN, sanitizer)
70
+ end
71
+ end
72
+
73
+ module ResourcePolicyDSL
74
+ # An index that answers no question until it is asked one: with no filter in the
75
+ # request (and none imposed by a default), render an empty collection with the notice
76
+ # instead of the whole table. Resource-level on purpose: "any filter at all" belongs
77
+ # to no single filter.
78
+ def require_any_filter(notice: nil)
79
+ config.require_any_filter_notice = notice || :require_any_filter
80
+ end
81
+ end
82
+
83
+ module ResourcePolicySettings
84
+ attr_accessor :require_any_filter_notice
85
+ end
86
+
87
+ module Controller
88
+ def self.included(base)
89
+ # Guarded the same way Redirect is: before_load re-fires on every dev reload.
90
+ return if base.instance_variable_defined?(:@active_admin_filter_extensions_guard)
91
+
92
+ base.instance_variable_set(:@active_admin_filter_extensions_guard, true)
93
+ base.before_action only: :index do
94
+ enforce_filter_value_policies
95
+ end
96
+ end
97
+
98
+ protected
99
+
100
+ # Attributes whose policy rejected a value on this request. The defaults notice reads
101
+ # this to stay quiet - one render, one message.
102
+ def filter_policy_errors
103
+ @filter_policy_errors ||= []
104
+ end
105
+
106
+ # Keys a :reset dropped this request. Their declared defaults refill regardless of the
107
+ # usual gate - the form always sends commit, and a refill promise that holds only on
108
+ # hand-typed URLs is not one.
109
+ def filter_policy_reset_keys
110
+ @filter_policy_reset_keys ||= []
111
+ end
112
+
113
+ def enforce_filter_value_policies
114
+ q = params[:q]
115
+ return enforce_require_any_filter if q.blank? || !q.respond_to?(:each_pair)
116
+
117
+ visible_filters.each do |attribute, options|
118
+ # A :reject already answered this request with a redirect; a second violation must
119
+ # not answer it again.
120
+ return if performed?
121
+
122
+ next unless options.values_at(:sanitize, :validate, :max_range, :min).any?
123
+
124
+ keys = policy_search_keys(attribute, options)
125
+ enforce_scalar_policies(q, attribute, options, keys | sibling_predicate_keys(q, keys))
126
+ enforce_window_policies(q, attribute, options, keys)
127
+ end
128
+
129
+ enforce_with_groups(q) unless performed?
130
+ # Last, not first: a policy may just have reset the only question the request asked,
131
+ # and the guard must notice within THIS request - CSV gets no second hop.
132
+ enforce_require_any_filter unless performed?
133
+ end
134
+
135
+ def enforce_require_any_filter
136
+ notice = active_admin_config.require_any_filter_notice
137
+ return if notice.nil? || any_declared_filter_answered?
138
+
139
+ @filter_policy_render_empty = true
140
+ # Active Admin reads a q-less index as "this table has no rows" and renders the blank
141
+ # slate, Create link and all; a present-but-empty q renders an empty result instead.
142
+ params[:q] ||= {}
143
+ flash.now[:warning] =
144
+ notice.is_a?(String) ? notice : I18n.t(notice, scope: :active_admin_filter_extensions,
145
+ default: :require_any_filter)
146
+ end
147
+
148
+ # Ransack silently ignores a condition nobody declared - junk, a stale bookmark after
149
+ # a filter rename, a predicate-less stem - and ignored is not asked. A key counts only
150
+ # when a declared filter submits it as-is (scope-named filters carry no predicate), or
151
+ # when it carries a real predicate on a declared filter's attribute.
152
+ def any_declared_filter_answered?
153
+ # A scope tab is a question, and the one Active Admin puts a count on: answering it
154
+ # with an empty page and "specify at least one filter" contradicts the tab. One Active
155
+ # Admin actually resolves, though - #current_scope is nil for an id no resource
156
+ # declares, and an unresolved scope narrows nothing, so counting its mere presence
157
+ # hands back the whole table behind a scope-shaped URL.
158
+ return true if params[:scope].present? && current_scope.present?
159
+
160
+ exact = visible_filters.flat_map { |attribute, options| policy_search_keys(attribute, options) }
161
+ bases = exact.map { |key| strip_ransack_predicate(key) }
162
+
163
+ filtering_params.any? do |key, value|
164
+ next false unless value.present? || value == false
165
+
166
+ key = key.to_s
167
+ exact.include?(key) ||
168
+ (key != strip_ransack_predicate(key) && bases.include?(strip_ransack_predicate(key)))
169
+ end
170
+ end
171
+
172
+ def enforce_with_groups(q)
173
+ # Membership is judged on the normalized request: a blank-array partner would pass a
174
+ # raw present? check while Ransack drops it - half the group executing silently.
175
+ answered = requested_filtering_params || {}
176
+ with_groups.each do |keys|
177
+ members = with_group_members(keys)
178
+ present = members.select { |member| member.any? { |key| answered.key?(key) } }
179
+ next if present.empty? || present.size == members.size
180
+
181
+ filter_policy_errors << keys
182
+ filter_policy_reset_keys.concat(keys)
183
+ names = keys.map { |k| strip_ransack_predicate(k).humanize }.uniq.join(" and ")
184
+ flash.now[:warning] = I18n.t(:given_together, scope: :active_admin_filter_extensions,
185
+ filters: names)
186
+ present.flatten.each { |key| q.delete(key) }
187
+ end
188
+ end
189
+
190
+ # A group's members, not its keys: a two-ended filter submits two of them and is answered
191
+ # by either bound - the touched-is-answered rule required: already follows. Counted as
192
+ # separate keys, one bound beside the partner read as a half-answered group.
193
+ #
194
+ # Only keys ONE input submits together are combined. Grouping by the stripped attribute
195
+ # instead would also merge two single-ended filters declared separately on one column
196
+ # (position_gteq with: position_lteq), and half that answer would pass as a whole group.
197
+ def with_group_members(keys)
198
+ shared = multi_key_filter_keys.filter_map do |input_keys|
199
+ own = input_keys & keys
200
+ own if own.size > 1
201
+ end
202
+
203
+ (keys - shared.flatten).map { |key| [key] } + shared
204
+ end
205
+
206
+ # The key sets of filters whose input submits more than one - the two-ended ones.
207
+ def multi_key_filter_keys
208
+ @multi_key_filter_keys ||= visible_filters.filter_map do |attribute, options|
209
+ keys = policy_search_keys(attribute, options)
210
+ keys if keys.size > 1
211
+ end
212
+ end
213
+
214
+ # `with: :year_eq` on a filter: its own search keys and the named partners are one
215
+ # group - some-but-not-all answered resets the present ones with the warning. Declared
216
+ # on either member; mirrored declarations dedupe to the one group they both name.
217
+ def with_groups
218
+ groups = visible_filters.filter_map do |attribute, options|
219
+ partners = options[:with]
220
+ next unless partners
221
+
222
+ (policy_search_keys(attribute, options) + Array(partners).map(&:to_s)).uniq.sort
223
+ end.uniq
224
+
225
+ # Overlapping groups are one question: resetting half of one must not orphan the
226
+ # other group's membership through the shared key.
227
+ groups.each_with_object([]) do |keys, merged|
228
+ overlapping, rest = merged.partition { |group| (group & keys).any? }
229
+ merged.replace(rest << (overlapping.flatten + keys).uniq.sort)
230
+ end
231
+ end
232
+
233
+ # The filter's own predicate dropdown submits _gt/_lt/_not_eq and friends beside the
234
+ # derived head key; a value policy guarding only the head leaves the same column one
235
+ # dropdown click from unpoliced. Siblings = request keys naming the same attribute.
236
+ def sibling_predicate_keys(q, keys)
237
+ bases = keys.map { |key| strip_ransack_predicate(key) }
238
+ q.keys.select { |key| bases.include?(strip_ransack_predicate(key)) }
239
+ end
240
+
241
+ def strip_ransack_predicate(name)
242
+ # Kept for the unit suite, which deliberately runs without Ransack (or Rails) loaded.
243
+ return name.to_s unless defined?(::Ransack::Predicate)
244
+
245
+ name = name.to_s.dup
246
+ ::Ransack::Predicate.detect_and_strip_from_string!(name)
247
+ name
248
+ end
249
+
250
+ # Deriving a key needs a resource Ransack can search; a proxy page backed by an HTTP API
251
+ # has none, and a policy must not turn its every index into a 500. On such pages the
252
+ # declared filter name IS the submitted key, so fall back to it.
253
+ def policy_search_keys(attribute, options)
254
+ filter_search_keys(attribute, options)
255
+ rescue ArgumentError
256
+ fallback_search_key_attributes << attribute
257
+ [attribute.to_s]
258
+ end
259
+
260
+ # Which filters got their key from the fallback rather than from their input. A window
261
+ # policy reads this: one key is a configuration error when the input said so, and merely
262
+ # unknowable when nothing could ask the input.
263
+ def fallback_search_key_attributes
264
+ @fallback_search_key_attributes ||= []
265
+ end
266
+
267
+ def enforce_scalar_policies(q, attribute, options, keys)
268
+ return unless options[:sanitize] || options[:validate]
269
+
270
+ keys.each do |key|
271
+ return if performed?
272
+
273
+ value = q[key]
274
+ next if value.nil? || (value.is_a?(String) && value.blank?)
275
+
276
+ original = value
277
+
278
+ # A `:check_boxes` or multi-select filter submits an Array of Strings - the shape
279
+ # that input legitimately has - so the policies judge it element by element. Any
280
+ # other shape (a Hash, a nested Array) executes byte-identically to the scalar on
281
+ # Ransack's side and is a bypass, not a shape to ignore.
282
+ unless policed_value_shape?(value)
283
+ apply_invalid_filter_policy(attribute, options, scalar_reset_scope(options, keys, key), :invalid)
284
+ next
285
+ end
286
+
287
+ value = value.reject(&:blank?) if value.is_a?(Array)
288
+
289
+ if (sanitizer = options[:sanitize])
290
+ sanitizer = Sanitizers.resolve(sanitizer)
291
+ sanitized = sanitize_policed_value(attribute, sanitizer, value)
292
+ value = sanitized
293
+ q[key] = value
294
+ end
295
+
296
+ next unless (validator = options[:validate])
297
+
298
+ validator = Validators.resolve(validator)
299
+ # Blank input array elements are discarded before searching - ?q[k][]=&q[k][]=draft
300
+ # is the shape a multi-select submits - so remove them before sanitizing. Sanitized
301
+ # elements are all judged, including ones the sanitizer emptied.
302
+ elements = value.is_a?(Array) ? value : [value]
303
+ message = elements.lazy
304
+ .filter_map { |element| ::MethodOrProcHelper.render_in_context(self, validator, element) }.first
305
+ # nil AND false mean "valid" - `condition && "message"` is how Ruby writes these.
306
+ next unless message
307
+
308
+ # :empty promises the ADMIN'S input stays on screen; the sanitized attempt
309
+ # searched nothing, so it has nothing to show for itself.
310
+ q[key] = original if options.fetch(:on_invalid, :reset) == :empty
311
+ apply_invalid_filter_policy(attribute, options, scalar_reset_scope(options, keys, key), message)
312
+ end
313
+ end
314
+
315
+ # A String, or an Array of Strings - what a multi-valued input submits.
316
+ def policed_value_shape?(value)
317
+ value.is_a?(String) || (value.is_a?(Array) && value.all?(String))
318
+ end
319
+
320
+ # Element-wise for an Array, so a multi-valued filter is sanitized the way it is
321
+ # submitted. On dup'd copies: a sanitizer that mutates in place and returns the same
322
+ # object would otherwise be compared against itself and wave its loop through.
323
+ def sanitize_policed_value(attribute, sanitizer, value)
324
+ return value.map { |element| sanitize_policed_value(attribute, sanitizer, element) } if value.is_a?(Array)
325
+
326
+ sanitized = ::MethodOrProcHelper.render_in_context(self, sanitizer, value.dup).to_s
327
+ # The sanitized value lands in the URL and comes back for another pass - a sanitizer
328
+ # that keeps rewriting its own output redirect-loops the index into unreachability.
329
+ # Catch it on the first request instead.
330
+ if sanitized != value &&
331
+ ::MethodOrProcHelper.render_in_context(self, sanitizer, sanitized.dup).to_s != sanitized
332
+ raise ArgumentError,
333
+ "filter :#{attribute}'s sanitize: is not idempotent " \
334
+ "(#{value.inspect} -> #{sanitized.inspect} -> keeps changing) - " \
335
+ "that is an infinite redirect"
336
+ end
337
+
338
+ sanitized
339
+ end
340
+
341
+ # On a window-policied filter a value violation resets ALL its keys, not just the
342
+ # offender: half the admin's window completed from the default pair is a window nobody
343
+ # asked for and nothing validated.
344
+ def scalar_reset_scope(options, keys, key)
345
+ options[:max_range] || options[:min] ? keys : [key]
346
+ end
347
+
348
+ # `max_range:` / `min:` treat the two bounds as one window: a violation drops the PAIR -
349
+ # a bad gteq with a good lteq is still not the range the admin meant - and the declared
350
+ # default refills.
351
+ def enforce_window_policies(q, attribute, options, keys)
352
+ # A :reject value policy may just have answered this request.
353
+ return if performed?
354
+ return unless options[:max_range] || options[:min]
355
+
356
+ unless keys.size == 2
357
+ # Nothing could ask the input what it submits, so one key is not a verdict about the
358
+ # declaration - and raising here is the 500 the fallback exists to prevent.
359
+ return warn_underivable_window(attribute) if fallback_search_key_attributes.include?(attribute)
360
+
361
+ raise ArgumentError,
362
+ "filter :#{attribute} declares max_range:/min: but its input submits " "#{keys.join(' and ')} - a window policy needs a two-ended input"
363
+ end
364
+
365
+ from_raw, to_raw = q.values_at(*keys)
366
+ return if from_raw.blank? && to_raw.blank?
367
+
368
+ violation = window_violation(options, from_raw, to_raw)
369
+ return unless violation
370
+
371
+ # The fallback's own sanity is checked once, at default materialization - in every
372
+ # mode, not only on the resetting path.
373
+ apply_invalid_filter_policy(attribute, options, keys, violation.first, violation.last)
374
+ end
375
+
376
+ # Once per resource: an index is hot, and the declaration is the same on every request.
377
+ def warn_underivable_window(attribute)
378
+ warned = self.class.instance_variable_get(:@extensions_underivable_windows) ||
379
+ self.class.instance_variable_set(:@extensions_underivable_windows, [])
380
+ return if warned.include?(attribute)
381
+
382
+ warned << attribute
383
+ policy_logger&.warn(
384
+ "filter :#{attribute} declares max_range:/min: but its search key could not be " \
385
+ "derived, so the window is not enforced - name the predicates with a Hash"
386
+ )
387
+ nil
388
+ end
389
+
390
+ # Kept for the unit suite, which deliberately runs without Rails loaded.
391
+ def policy_logger
392
+ ::Rails.logger if defined?(::Rails)
393
+ end
394
+
395
+ def window_violation(options, from_raw, to_raw)
396
+ from = parse_window_bound(from_raw)
397
+ to = parse_window_bound(to_raw)
398
+
399
+ return [:range_invalid, {}] if (from_raw.present? && from.nil?) || (to_raw.present? && to.nil?)
400
+ return [:range_invalid, {}] if from && to && from > to
401
+
402
+ floor = window_floor(options)
403
+
404
+ if floor && [from, to].compact.any? { |bound| bound < floor }
405
+ return [:before_floor, { floor: floor.to_date }]
406
+ end
407
+
408
+ if (max = options[:max_range])
409
+ # An upper bound alone is an infinitely wide window - the outage query the cap
410
+ # exists to refuse.
411
+ return [:range_too_wide, { limit: window_limit_text(max) }] if from.nil? && to
412
+
413
+ if from
414
+ # An open upper bound is measured against now; a FUTURE lower bound with no upper
415
+ # bound selects a not-yet-written window and passes - measuring it against now
416
+ # would read it as inverted.
417
+ return nil if to.nil? && from >= Time.current
418
+ return [:range_too_wide, { limit: window_limit_text(max) }] if (to || Time.current) - from > max
419
+ end
420
+ end
421
+
422
+ # A min: standing alone still refuses a window with no lower bound - everything
423
+ # before the floor is the query the floor exists to refuse. After max_range:, whose
424
+ # too-wide verdict is the more actionable one when both are declared.
425
+ return [:before_floor, { floor: floor.to_date }] if from.nil? && to && floor
426
+
427
+ nil
428
+ end
429
+
430
+ # The cap in the units it was declared in: divided into whole days, every cap shorter than
431
+ # one reported itself as "0 days".
432
+ def window_limit_text(max)
433
+ max.is_a?(::ActiveSupport::Duration) ? max.inspect : "#{max.to_i / 1.day} days"
434
+ end
435
+
436
+ # `min:` resolved for this request, once. nil imposes nothing - that is how a per-admin
437
+ # floor opts out, the way a nil default: does.
438
+ def window_floor(options)
439
+ floor = options[:min]
440
+ return nil unless floor
441
+
442
+ # in_time_zone, not to_time: the bounds are parsed in Time.zone, and Date#to_time speaks
443
+ # the SERVER zone - apart, a floor-equal bound is off by the offset.
444
+ ::MethodOrProcHelper.render_in_context(self, floor)&.in_time_zone
445
+ end
446
+
447
+ def parse_window_bound(raw)
448
+ return nil if raw.blank?
449
+ # An Array/Hash-wrapped bound would date-parse via #to_s while Ransack silently drops
450
+ # the wrapped condition - a bypass, not a date. Non-String time-likes stay parseable:
451
+ # defaults materialize as Time/Date objects.
452
+ return nil unless raw.is_a?(String) || raw.respond_to?(:to_time)
453
+
454
+ Time.zone.parse(raw.to_s)
455
+ rescue ArgumentError, TypeError
456
+ nil
457
+ end
458
+
459
+ # What a violated policy does, by name. :reset (default) drops the key(s) and lets the
460
+ # declared default refill; :reject bounces back the way it came; :empty renders nothing
461
+ # but keeps the admin's input on screen with the message.
462
+ def apply_invalid_filter_policy(attribute, options, keys, message, interpolations = {})
463
+ filter_policy_errors << attribute
464
+ text = filter_policy_message(attribute, options, message, interpolations)
465
+
466
+ case options.fetch(:on_invalid, :reset)
467
+ when :reset
468
+ reset_violating_filter_keys(text, keys)
469
+ when :reject
470
+ # Only where a redirect means something. A CSV or JSON index is answered in one
471
+ # request everywhere else in the gem, a flash it cannot render would be lost, and
472
+ # url_for drops the format - so off the HTML path the export is bounded the :reset way.
473
+ return reset_violating_filter_keys(text, keys) unless request.format.html?
474
+
475
+ flash[:error] = text
476
+ # Browsers keep the original Referer across a 302, so when back IS this index the
477
+ # bounce would re-reject itself to the browser's redirect cap. Home instead.
478
+ referer_path = begin
479
+ URI.parse(request.referer.to_s).path
480
+ rescue URI::InvalidURIError
481
+ nil
482
+ end
483
+ if referer_path == request.path
484
+ redirect_to url_for(action: :index)
485
+ else
486
+ redirect_back fallback_location: url_for(action: :index)
487
+ end
488
+ when :empty
489
+ flash.now[:error] = text
490
+ @filter_policy_render_empty = true
491
+ else
492
+ raise ArgumentError, "on_invalid: #{options[:on_invalid].inspect} - " "must be :reset, :reject or :empty"
493
+ end
494
+ end
495
+
496
+ # Drop the offending key(s) and let the declared default refill through the merge.
497
+ def reset_violating_filter_keys(text, keys)
498
+ flash.now[:error] = text
499
+ filter_policy_reset_keys.concat(keys)
500
+ keys.each { |key| params[:q].delete(key) }
501
+ end
502
+
503
+ # Read by apply_filtering: an :empty policy keeps the request intact - the form must
504
+ # still show what the admin typed - and blanks the collection instead.
505
+ def filter_policy_render_empty?
506
+ !!@filter_policy_render_empty
507
+ end
508
+
509
+ def filter_policy_message(attribute, options, message, interpolations = {})
510
+ return message if message.is_a?(String)
511
+
512
+ values = { filter: options[:label] || attribute.to_s.humanize }.merge(interpolations)
513
+ # `message:` on the filter speaks for every symbolic violation of this filter - the
514
+ # app's own words without duplicating a whole validator to say them. Interpolated, so
515
+ # those words can name %{limit} and %{floor} as the shipped texts do.
516
+ if (override = options[:message])
517
+ return begin
518
+ I18n.interpolate(override, values)
519
+ rescue I18n::ArgumentError
520
+ override
521
+ end
522
+ end
523
+
524
+ # The English texts ship as plain i18n (config/locales/en.yml); an application
525
+ # overrides them under the same keys.
526
+ policy_translation(message, values)
527
+ end
528
+
529
+ # A validate: may answer any Symbol in the gem's scope, including the parameterized ones -
530
+ # which have nothing to interpolate from. A violation message is not worth a 500, so an
531
+ # unknown key and a missing interpolation both read as :invalid.
532
+ def policy_translation(message, values)
533
+ I18n.t(message, scope: :active_admin_filter_extensions, raise: true, **values)
534
+ rescue I18n::ArgumentError
535
+ I18n.t(:invalid, scope: :active_admin_filter_extensions, **values)
536
+ end
537
+ end
538
+ end
539
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rack/utils"
4
+
5
+ module ActiveAdminFilterExtensions
6
+ # A URL that means the page. A bare HTML GET of the index redirects once to itself with the
7
+ # effective filters spelled out in the query string, so the address the admin copies carries
8
+ # the same window, the same values, for whoever opens it: a relative default is frozen to
9
+ # the dates it came to, a per-admin default to the values this admin saw. The price: +1
10
+ # redirect on every bare visit, and a bookmarked URL pins the defaults of the day it was
11
+ # made rather than following the code.
12
+ module Redirect
13
+ # The filters as a URL can carry them, so that the two sides of the comparison below are
14
+ # comparable at all. A value the URL drops or hands back in another shape must be absent on
15
+ # both sides or the hop alternates with the bare address forever: an empty Array or Hash is
16
+ # dropped by to_query entirely, ?q[k]= comes back with the blank stripped, and an Array of
17
+ # blanks comes back as the empty Array. `false` round-trips, and is a real answer.
18
+ def self.canonical_filters(filters)
19
+ filters.each_with_object({}) do |(key, value), result|
20
+ value = value.reject { |element| element != false && element.blank? } if value.is_a?(Enumerable) && !value.is_a?(Hash)
21
+ next if value != false && value.blank?
22
+
23
+ result[key] = value
24
+ end
25
+ end
26
+
27
+ def self.included(base)
28
+ # ActiveAdmin.before_load fires on every dev-mode reload against the same non-reloaded
29
+ # controller class, and Ruby runs this hook on every include call - without the guard the
30
+ # callback would be registered again on each reload.
31
+ return if base.instance_variable_defined?(:@active_admin_filter_extensions_redirect)
32
+
33
+ base.instance_variable_set(:@active_admin_filter_extensions_redirect, true)
34
+ base.before_action only: :index do
35
+ # Only where an address bar is watching. The defaults themselves do not depend on the
36
+ # redirect - #filtering_params applies them to a CSV or JSON index all the same - so a
37
+ # non-HTML request just keeps its one-request shape.
38
+ next unless request.get? && request.format.html?
39
+ # An :empty verdict renders THIS request - nothing to show, the admin's input kept on
40
+ # screen. The canonicalizing redirect would launder the flagged value away and the
41
+ # next request, flagless, would render the whole table.
42
+ next if filter_policy_render_empty?
43
+
44
+ effective = filtering_params
45
+ # Compared against the URL's own q - not params[:q], which a sanitize: policy may
46
+ # have already rewritten. The point of the comparison is "does the address bar tell the
47
+ # truth about this search"; a rewritten value is exactly a lie to correct with the 302.
48
+ requested = Rack::Utils.parse_nested_query(request.query_string)["q"]
49
+ # `?q=` parses to an empty String; treat it the way #filtering_params does - as absent -
50
+ # so the equality below compares Hashes on both sides.
51
+ requested = nil unless requested.is_a?(Hash)
52
+
53
+ effective = Redirect.canonical_filters(effective)
54
+ requested = Redirect.canonical_filters(requested) if requested
55
+
56
+ # One redirect, not a loop, whatever #filter_defaults_apply? has been widened to: once
57
+ # the query string says exactly what the search runs, the two sides are equal and the
58
+ # request passes through. An effective side EMPTIER than the URL redirects too - a
59
+ # policy may have reset the only key, and the bare address is the truthful one.
60
+ next if effective == (requested || {})
61
+
62
+ # Everything in the flash is addressed to the page this redirect is on the way to:
63
+ # messages that arrived with the request (a batch action's "n records done") and
64
+ # anything this request wrote with flash.now (a filter policy's error). keep un-marks
65
+ # them all for sweeping, so they ride the 302 instead of dying with it.
66
+ flash.keep
67
+
68
+ # This request is the one a declared notice belongs on, and it is about to answer 302,
69
+ # so the message is written to the travelling flash.
70
+ if (message = active_admin_config.default_filters_notice) &&
71
+ filter_policy_errors.empty? && filter_defaults_engaged?
72
+ # On a shared key - the default is :notice, the same key a batch action writes - the
73
+ # arriving message wins: it names what just happened, the notice can wait.
74
+ key = active_admin_config.default_filters_notice_flash_key
75
+ flash[key] = ::MethodOrProcHelper.render_in_context(self, message) if flash[key].blank?
76
+ end
77
+
78
+ redirect_to "#{request.path}?#{request.query_parameters.merge('q' => effective).to_query}"
79
+ end
80
+ end
81
+ end
82
+ end