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,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
|