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