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,637 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Rails template that builds the sample app the specs run against.
4
+
5
+ # Rails 8 no longer generates a Sprockets manifest, and Active Admin 3 needs one.
6
+ FileUtils.mkdir_p("app/assets/config")
7
+ File.write("app/assets/config/manifest.js", "//= link_tree ../images\n")
8
+
9
+ # One column per filter input type, so every input can be exercised against a real index.
10
+ generate :model,
11
+ "post title:string body:text status:string position:integer " \
12
+ "published_date:date starred:boolean --force"
13
+
14
+ inject_into_file "app/models/post.rb",
15
+ " def self.ransackable_attributes(auth_object = nil)\n" \
16
+ " %w[id title body status position published_date starred]\n" \
17
+ " end\n" \
18
+ " def self.ransackable_associations(auth_object = nil)\n" \
19
+ " []\n" \
20
+ " end\n",
21
+ after: "ApplicationRecord\n"
22
+
23
+ # Stands in for the signed-in admin carrying their own saved filter preset. Devise is out of the
24
+ # picture - the specs disable authentication - so `current_admin_user` is defined by hand below.
25
+ generate :model, "admin_user email:string default_status:string --force"
26
+
27
+ # Put the gem under test on the load path of the generated app.
28
+ gem_lib = File.expand_path(File.join(File.dirname(__FILE__), "..", "..", "lib"))
29
+ gsub_file "config/environment.rb",
30
+ 'require_relative "application"',
31
+ "require_relative \"application\"\n" \
32
+ "$LOAD_PATH.unshift('#{gem_lib}')\n" \
33
+ "require \"active_admin_filter_extensions\"\n"
34
+
35
+ file "config/locales/filter_extensions.en.yml", <<~YAML
36
+ # Overrides one gem message the i18n way - the suite proves the seam.
37
+ en:
38
+ active_admin_filter_extensions:
39
+ before_floor: "%{filter}: starts no earlier than %{floor} (retention)."
40
+ YAML
41
+
42
+ generate :"active_admin:install --skip-users"
43
+ generate :"formtastic:install"
44
+
45
+ file "config/initializers/current_admin_user.rb", <<~RUBY
46
+ # No Devise here: the specs disable Active Admin authentication and this is all
47
+ # `default: -> { current_admin_user... }` needs to resolve.
48
+ ActiveAdmin.before_load do
49
+ ActiveAdmin::BaseController.class_eval do
50
+ def current_admin_user
51
+ AdminUser.first
52
+ end
53
+ helper_method :current_admin_user
54
+ end
55
+ end
56
+ RUBY
57
+
58
+ # The sample app registers Post once per scenario - resources are cheap and a single index
59
+ # carrying every default at once would filter itself down to nothing.
60
+ file "app/admin/posts.rb", <<~RUBY
61
+ ActiveAdmin.register Post do
62
+ filter :status, as: :select, collection: %w[draft published], extensions: { default: "published" }
63
+ filter :published_date, as: :date_range, extensions: { default: -> { Date.new(2026, 1, 1).. } }
64
+
65
+ csv do
66
+ column :title
67
+ end
68
+
69
+ # One registration per filter input type - separate resources, because a single index
70
+ # carrying every default at once would filter itself down to nothing. Only a filter name
71
+ # that already carries its predicate takes a scalar (ScalarPost); for everything else the
72
+ # input decides the search key: `:numeric` submits `_eq`/`_gt`/`_lt`, `:date_range`
73
+ # submits `_gteq` and `_lteq`, `:check_boxes` submits `_in[]`.
74
+ ActiveAdmin.register Post, as: "StringPost" do
75
+ filter :title, extensions: { default: "keep" }
76
+ end
77
+
78
+ ActiveAdmin.register Post, as: "ScalarPost" do
79
+ filter :title_cont, as: :string, extensions: { default: "keep" }
80
+ end
81
+
82
+ ActiveAdmin.register Post, as: "NumericPost" do
83
+ filter :position, as: :numeric, extensions: { default: { gt: 10 } }
84
+ end
85
+
86
+ ActiveAdmin.register Post, as: "DatePost" do
87
+ filter :published_date, as: :date_range, extensions: { default: Date.new(2026, 1, 1).. }
88
+ end
89
+
90
+ ActiveAdmin.register Post, as: "SelectPost" do
91
+ filter :status, as: :select, collection: %w[draft published], extensions: { default: "published" }
92
+ end
93
+
94
+ ActiveAdmin.register Post, as: "CheckBoxesPost" do
95
+ filter :status, as: :check_boxes, collection: %w[draft published], extensions: { default: ["published"] }
96
+ end
97
+
98
+ ActiveAdmin.register Post, as: "PolicedBoxesPost" do
99
+ # A multi-valued filter under a value policy: _in[] submits an Array of Strings, which
100
+ # is the shape this input legitimately has - not a bypass.
101
+ filter :status, as: :check_boxes, collection: %w[draft published],
102
+ extensions: { default: ["published"],
103
+ validate: lambda { |value|
104
+ next "Letters only." unless value.match?(/\\A[a-z]+\\z/)
105
+
106
+ "No x statuses." if value.start_with?("x")
107
+ } }
108
+ end
109
+
110
+ ActiveAdmin.register Post, as: "IntegerSanitizedBoxesPost" do
111
+ filter :position, as: :check_boxes, collection: [1, 20],
112
+ extensions: { sanitize: ->(value) { Integer(value, 10).to_s }, validate: :integer }
113
+ end
114
+
115
+ ActiveAdmin.register Post, as: "NilMemberPolicedPost" do
116
+ # An Array default with a blank member beside a real one - `[current_admin_user.team,
117
+ # "published"]` for an admin with no team. The blank is not a value the request would
118
+ # search, so it is not one the validator should judge.
119
+ filter :status, as: :check_boxes, collection: %w[draft published],
120
+ extensions: { default: [nil, "published"],
121
+ validate: lambda { |value|
122
+ next "Letters only." unless value.match?(/\\A[a-z]+\\z/)
123
+
124
+ "No x statuses." if value.start_with?("x")
125
+ } }
126
+ end
127
+
128
+ ActiveAdmin.register Post, as: "BooleanPost" do
129
+ filter :starred, extensions: { default: true }
130
+ end
131
+
132
+ ActiveAdmin.register Post, as: "AdminPreferencePost" do
133
+ filter :status_eq, as: :select, collection: %w[draft published],
134
+ extensions: { default: -> { current_admin_user.default_status } }
135
+ end
136
+
137
+ ActiveAdmin.register Post, as: "ConditionalPost" do
138
+ filter :title, extensions: { default: "keep" }, if: -> { false }
139
+ filter :body, extensions: { default: "keep" }, unless: -> { true }
140
+ filter :status, as: :select, collection: %w[draft published],
141
+ extensions: { default: "published" }, if: -> { true }
142
+ end
143
+
144
+ ActiveAdmin.register Post, as: "PlainPost" do
145
+ filter :title
146
+ end
147
+
148
+ ActiveAdmin.register Post, as: "AmbiguousPost" do
149
+ filter :published_date, as: :date_range, extensions: { default: Date.new(2026, 1, 1) }
150
+ end
151
+
152
+ ActiveAdmin.register Post, as: "BananaExclusiveRangePost" do
153
+ # The excluded end has no gteq/lteq spelling - the suite proves that raises instead of
154
+ # searching the day the Range left out.
155
+ filter :published_date, as: :date_range,
156
+ extensions: { default: -> { Date.new(2026, 1, 1)...Date.new(2026, 2, 1) } }
157
+ end
158
+
159
+ ActiveAdmin.register Post, as: "ProcInputHtmlPost" do
160
+ filter :status, as: :select, collection: %w[draft published],
161
+ input_html: proc { { class: "select2" } }, extensions: { default: "published" }
162
+ end
163
+
164
+ ActiveAdmin.register Post, as: "AllHiddenPost" do
165
+ filter :title, if: -> { false }
166
+ end
167
+
168
+
169
+ # Stands in for any action that flashes and sends the admin back to the index - a batch
170
+ # action, a callback - whose flash must survive the index's own redirect.
171
+ collection_action :poke do
172
+ redirect_to collection_path, notice: "poked"
173
+ end
174
+ end
175
+
176
+ ActiveAdmin.register Post, as: "EmptyDefaultPost" do
177
+ # A default that resolves to an empty Array - `-> { current_admin_user.visible_ids }` on an
178
+ # admin with none - is a value to_query cannot carry into a URL.
179
+ filter :position, as: :numeric, extensions: { default: { in: -> { [] } } }
180
+ end
181
+
182
+ ActiveAdmin.register Post, as: "NilMemberDefaultPost" do
183
+ # `default: -> { [current_admin_user.team_id] }` for an admin with no team: an Array whose
184
+ # only member is blank survives a plain blank-reject but cannot round-trip through a URL.
185
+ filter :status, as: :check_boxes, collection: %w[draft published],
186
+ extensions: { default: -> { [nil] } }
187
+ end
188
+
189
+ ActiveAdmin.register Post, as: "BlankDefaultPolicedPost" do
190
+ # A blank default beside a validate:. Blank is no value - the URL cannot even carry it -
191
+ # so there is nothing for the validator to refuse.
192
+ filter :title_cont, as: :string,
193
+ extensions: { default: "",
194
+ validate: ->(value) { "Letters only." unless value.match?(/\\A[a-z]+\\z/) } }
195
+ end
196
+
197
+ ActiveAdmin.register Post, as: "BananaBlankDefaultPost" do
198
+ # A blank default is a value ?q[k]= cannot round-trip (the blank is stripped on the way
199
+ # back in) - it must stay out of the redirect, not loop it.
200
+ filter :title_cont, as: :string, extensions: { default: "" }
201
+ end
202
+
203
+ ActiveAdmin.register Post, as: "WidenedPost" do
204
+ filter :status, as: :select, collection: %w[draft published], extensions: { default: "published" }
205
+
206
+ # The README's widening example: defaults still apply to a request that carries only its
207
+ # pinned key. The redirect must add the default beside it - in ONE hop.
208
+ controller do
209
+ def filter_defaults_apply?
210
+ super || params[:q].keys == %w[title_cont]
211
+ end
212
+ end
213
+ end
214
+
215
+ ActiveAdmin.register Post, as: "WidenedNoticePost" do
216
+ default_filters_notice "Showing published posts by default"
217
+
218
+ filter :title_cont, as: :string
219
+ filter :status, as: :select, collection: %w[draft published], extensions: { default: "published" }
220
+
221
+ # Widened: a status+title request still counts as one the defaults apply to - but the
222
+ # notice must only speak when a default actually reached the search.
223
+ controller do
224
+ def filter_defaults_apply?
225
+ super || (params[:q].keys - %w[status_eq title_cont]).empty?
226
+ end
227
+ end
228
+ end
229
+
230
+ ActiveAdmin.register Post, as: "GuardedPost" do
231
+ default_filters_notice "Showing kept posts by default"
232
+
233
+ # A value policy next to the default: strip what typing drags in, refuse what the
234
+ # search cannot mean, and say so - the home-grown before_action guards, as options.
235
+ filter :title_cont, as: :string,
236
+ extensions: { default: "keep",
237
+ sanitize: ->(value) { value.delete("-") },
238
+ validate: ->(value) { value.match?(/\\d/) && "Title filter must not contain digits." } }
239
+ end
240
+
241
+ ActiveAdmin.register Post, as: "RequiredPost" do
242
+ default_filters_notice "Showing published posts by default"
243
+
244
+ filter :id_eq, as: :string, label: "ID"
245
+ filter :title_cont, as: :string
246
+ # unless: - a search that names a unique record; the window steps aside for it.
247
+ filter :status, as: :select, collection: %w[draft published],
248
+ extensions: { default: "published", required: { unless: %i[id_eq] } }
249
+ end
250
+
251
+ ActiveAdmin.register Post, as: "BananaRequiredOptionPost" do
252
+ # An unknown required: option must raise, not silently mean true.
253
+ filter :status, as: :select, collection: %w[draft published],
254
+ extensions: { default: "published", required: { iff: %i[title_cont] } }
255
+ filter :title_cont, as: :string
256
+ end
257
+
258
+ ActiveAdmin.register Post, as: "PinnedWindowPost" do
259
+ default_filters_notice "Showing published posts by default"
260
+
261
+ filter :position
262
+ filter :title_cont, as: :string
263
+ # if: - the window matters only beside these keys; alone they scan too broadly.
264
+ filter :status, as: :select, collection: %w[draft published],
265
+ extensions: { default: "published", required: { if: :title_cont } }
266
+ end
267
+
268
+ ActiveAdmin.register Post, as: "RequiredSiblingPost" do
269
+ default_filters_notice "Showing kept titles by default"
270
+
271
+ # A :string filter carries the predicate dropdown: picking "equals" submits title_eq
272
+ # where the derived key is title_cont. Both are answers about the same column.
273
+ filter :title, extensions: { default: "keep", required: true }
274
+ end
275
+
276
+ ActiveAdmin.register Post, as: "RequiredBananaPost" do
277
+ # required with nothing to fall back on - the suite proves that raises.
278
+ filter :title_cont, as: :string, extensions: { required: true }
279
+ end
280
+
281
+ ActiveAdmin.register Post, as: "RequiredOpenPost" do
282
+ # A required window with an endless default: the group is answered by ANY of the
283
+ # filter's own keys, not only the ones the default happens to fill.
284
+ filter :published_date, as: :date_range,
285
+ extensions: { required: true, default: -> { Date.new(2026, 5, 1).. } }
286
+ end
287
+
288
+ ActiveAdmin.register Post, as: "WindowPost" do
289
+ default_filters_notice "Showing the default window"
290
+
291
+ # The capped window: never unbounded (required:), never wider than the cap, never before
292
+ # the retention floor - the didww-rs/pbx/nessie range guards, as options.
293
+ filter :published_date, as: :date_range,
294
+ extensions: { required: true,
295
+ max_range: 90.days,
296
+ min: -> { Date.new(2020, 6, 1) },
297
+ default: -> { Date.new(2026, 5, 1)..Date.new(2026, 6, 30) } }
298
+ end
299
+
300
+ ActiveAdmin.register Post, as: "FloorPost" do
301
+ # min: standing alone: a window with no lower bound scans everything before the
302
+ # retention floor - the exact query the floor exists to refuse.
303
+ filter :published_date, as: :date_range,
304
+ extensions: { min: -> { Date.new(2020, 6, 1) },
305
+ default: -> { Date.new(2026, 5, 1)..Date.new(2026, 6, 30) } }
306
+ end
307
+
308
+ ActiveAdmin.register Post, as: "ShortWindowPost" do
309
+ # A cap shorter than a day - reported in whole days it read "0 days".
310
+ filter :published_date, as: :date_range, extensions: { max_range: 6.hours }
311
+ end
312
+
313
+ ActiveAdmin.register Post, as: "StringKeyedExtensionsPost" do
314
+ # A string-keyed container: accepted by the unknown-key check and then silently inert.
315
+ filter :title_cont, as: :string, extensions: { "default" => "keep" }
316
+ end
317
+
318
+ ActiveAdmin.register Post, as: "NilFloorPost" do
319
+ # A per-admin retention floor that opts out for this admin - nil imposes nothing, the way
320
+ # a nil default: does.
321
+ filter :published_date, as: :date_range, extensions: { min: -> { nil } }
322
+ end
323
+
324
+ ActiveAdmin.register Post, as: "ValidatedWindowPost" do
325
+ # Value AND window policies with a default: a scalar reset must not leave half the
326
+ # admin's window to be completed from the default pair unchecked.
327
+ filter :published_date, as: :date_range,
328
+ extensions: { max_range: 90.days,
329
+ validate: ->(value) { value.include?("X") && "No X dates." },
330
+ default: -> { Date.new(2026, 5, 1)..Date.new(2026, 6, 30) } }
331
+ end
332
+
333
+ ActiveAdmin.register Post, as: "BananaSanitizedWindowPost" do
334
+ # Its sanitizer widens a bound past the cap AFTER the declared range looks innocent -
335
+ # the window must be judged on what is actually stored.
336
+ filter :published_date, as: :date_range,
337
+ extensions: { max_range: 90.days,
338
+ sanitize: ->(value) { value == "2026-02-01" ? "2026-12-31" : value },
339
+ default: -> { Date.new(2026, 1, 1)..Date.new(2026, 2, 1) } }
340
+ end
341
+
342
+ ActiveAdmin.register Post, as: "BananaFlatOptionPost" do
343
+ # A gem option left outside extensions: raises - flat, it would mean Formtastic things.
344
+ filter :title_cont, as: :string, default: "keep"
345
+ end
346
+
347
+ ActiveAdmin.register Post, as: "HostOptionPost" do
348
+ # required: and min: are Formtastic input options in their own right. The gem answers to
349
+ # them only inside extensions:, so flat they belong to the form - not to a window policy.
350
+ filter :position, as: :number, required: true, min: 0
351
+ end
352
+
353
+ ActiveAdmin.register Post, as: "BananaPolicyKeyPost" do
354
+ # A typo inside extensions: raises instead of silently guarding nothing.
355
+ filter :title_cont, as: :string, extensions: { defult: "keep" }
356
+ end
357
+
358
+ ActiveAdmin.register Post, as: "BananaSanitizeBuiltinPost" do
359
+ # An unknown built-in name must raise, not fall through to a controller method call.
360
+ filter :title_cont, as: :string, extensions: { sanitize: :shout }
361
+ end
362
+
363
+ ActiveAdmin.register Post, as: "IntegerPost" do
364
+ # The built-in numeric pair every app hand-rolled: strip to digits, refuse what does
365
+ # not fit a PG int4.
366
+ filter :position_eq, as: :string, extensions: { sanitize: :digits, validate: :integer }
367
+ end
368
+
369
+ ActiveAdmin.register Post, as: "DigitDefaultPost" do
370
+ # The default is stored sanitized: what Ransack runs and what the URL says must be the
371
+ # value the policy produced, on the first pass and on every format.
372
+ filter :position_eq, as: :string, extensions: { default: "2-0a", sanitize: :digits, validate: :integer }
373
+ end
374
+
375
+ ActiveAdmin.register Post, as: "SignedDefaultPost" do
376
+ # A non-String default still goes through the sanitizer, as the String the URL will
377
+ # say - or the CSV and the HTML search different values.
378
+ filter :position_eq, as: :string, extensions: { default: -20, sanitize: :digits, validate: :integer }
379
+ end
380
+
381
+ ActiveAdmin.register Post, as: "BananaIntegerDefaultPost" do
382
+ # Its default is no String, but the URL will say it as one - validated as submitted.
383
+ filter :position_eq, as: :string, extensions: { default: 2_147_483_648, validate: :integer }
384
+ end
385
+
386
+ ActiveAdmin.register Post, as: "BananaValidatedDefaultPost" do
387
+ # Its default violates its own validate: - the suite proves that raises instead of
388
+ # flashing itself on every bare visit, forever.
389
+ filter :title_cont, as: :string,
390
+ extensions: { default: "v1",
391
+ validate: ->(value) { value.match?(/\\d/) && "no digits" } }
392
+ end
393
+
394
+ ActiveAdmin.register Post, as: "BananaBeginlessWindowPost" do
395
+ # Its beginless default is unbounded below the cap - the suite proves that raises
396
+ # instead of silently running the over-cap scan on every bare visit.
397
+ filter :published_date, as: :date_range,
398
+ extensions: { max_range: 90.days,
399
+ default: -> { (..(Date.current + 1)) } }
400
+ end
401
+
402
+ ActiveAdmin.register Post, as: "BananaWindowPost" do
403
+ # Its default violates its own cap - the suite proves that raises instead of looping.
404
+ filter :published_date, as: :date_range,
405
+ extensions: { max_range: 90.days,
406
+ default: -> { Date.new(2026, 1, 1).. } }
407
+ end
408
+
409
+ ActiveAdmin.register Post, as: "RejectPost" do
410
+ # :reject - the didww-api-3 add_integer_filter_check shape: flash and bounce back.
411
+ filter :title_cont, as: :string,
412
+ extensions: { on_invalid: :reject,
413
+ validate: ->(value) { value.match?(/\\d/) && "Title must not contain digits." } }
414
+ filter :position, as: :numeric,
415
+ extensions: { on_invalid: :reject,
416
+ validate: ->(value) { :invalid unless value.match?(/\\A\\d+\\z/) } }
417
+ end
418
+
419
+ ActiveAdmin.register Post, as: "EmptyPost" do
420
+ # :empty - the invoice name_eq shape: show nothing, but SAY WHY, and keep the admin's
421
+ # input in the form so they can fix it.
422
+ filter :title_cont, as: :string,
423
+ extensions: { on_invalid: :empty,
424
+ validate: ->(value) { value.match?(/\\d/) && "Title filter must not contain digits." } }
425
+ end
426
+
427
+ ActiveAdmin.register Post, as: "PairedPost" do
428
+ # The year/month shape: either both halves of the pair or neither - half a key is a
429
+ # question the index cannot answer honestly. Declared on both members on purpose: the
430
+ # mirrored declarations must dedupe to one group, one warning.
431
+ filter :status, as: :select, collection: %w[draft published],
432
+ extensions: { default: "published", with: :starred_eq }
433
+ filter :starred, extensions: { default: true, with: :status_eq }
434
+ end
435
+
436
+ ActiveAdmin.register Post, as: "RequiredPairPost" do
437
+ # A required member of a with: group: its refill must pull the partner's default along,
438
+ # or the merge hands Ransack the half-group the enforcement just promised away.
439
+ filter :title_cont, as: :string
440
+ filter :status, as: :select, collection: %w[draft published],
441
+ extensions: { default: "published", required: true, with: :starred_eq }
442
+ filter :starred, extensions: { default: false }
443
+ end
444
+
445
+ ActiveAdmin.register Post, as: "ChainedPairPost" do
446
+ # Two with: groups sharing status_eq - resetting one half must not orphan the other
447
+ # group's membership: the overlap makes them one question.
448
+ filter :title_cont, as: :string, extensions: { with: :status_eq }
449
+ filter :status, as: :select, collection: %w[draft published],
450
+ extensions: { with: :starred_eq }
451
+ filter :starred
452
+ end
453
+
454
+ ActiveAdmin.register Post, as: "NilPairedPost" do
455
+ default_filters_notice "Showing published starred posts by default"
456
+
457
+ # A member of a with: group whose default opts out for this admin. Declared, so the group
458
+ # is complete; nil, so it imposes nothing - and neither does the partner.
459
+ filter :status, as: :select, collection: %w[draft published],
460
+ extensions: { default: "published", with: :starred_eq }
461
+ filter :starred, extensions: { default: -> { nil } }
462
+ end
463
+
464
+ ActiveAdmin.register Post, as: "BananaHalfPairedPost" do
465
+ # Half the with: group declares a default - the fallback would execute the very
466
+ # half-group the enforcement refuses. The suite proves that raises.
467
+ filter :status, as: :select, collection: %w[draft published],
468
+ extensions: { default: "published", with: :starred_eq }
469
+ filter :starred
470
+ end
471
+
472
+ ActiveAdmin.register Post, as: "SplitPairPost" do
473
+ # Two single-ended filters on ONE attribute, linked by with:. Grouped by stripped
474
+ # attribute they look like one two-ended input, so half an answer passed as a whole group.
475
+ filter :position_gteq, as: :string, extensions: { with: :position_lteq }
476
+ filter :position_lteq, as: :string
477
+ end
478
+
479
+ ActiveAdmin.register Post, as: "BananaSplitPairPost" do
480
+ # Only one half of a split pair declares a default - the fallback would run the very
481
+ # half-group the enforcement refuses. The suite proves that raises.
482
+ filter :position_gteq, as: :string, extensions: { default: "10", with: :position_lteq }
483
+ filter :position_lteq, as: :string
484
+ end
485
+
486
+ ActiveAdmin.register Post, as: "PairedDatePost" do
487
+ # Declared on ONE member - the partner's side must trip the same group - and on a
488
+ # two-ended filter, whose predicates the warning must still humanize away.
489
+ filter :published_date, as: :date_range, extensions: { with: :position_eq }
490
+ filter :position
491
+ end
492
+
493
+ ActiveAdmin.register Post, as: "OpenPairedPost" do
494
+ # A two-ended member of a with: group whose default fills only the end it has - the group
495
+ # is answered by that bound, not left half-declared.
496
+ filter :published_date, as: :date_range,
497
+ extensions: { default: -> { Date.new(2026, 5, 1).. }, with: :position_eq }
498
+ filter :position, extensions: { default: { eq: 20 } }
499
+ end
500
+
501
+ ActiveAdmin.register Post, as: "PickyPost" do
502
+ # The active-calls shape: this index answers no question at all until it is asked one.
503
+ require_any_filter notice: "You must specify at least one filter"
504
+
505
+ # A scope tab is a question too - and the one Active Admin puts a count on.
506
+ scope(:published) { |posts| posts.where(status: "published") }
507
+
508
+ filter :title_cont, as: :string,
509
+ extensions: { validate: ->(value) { value.match?(/\\d/) && "Title must not contain digits." } }
510
+ filter :status, as: :select, collection: %w[draft published]
511
+ end
512
+
513
+ ActiveAdmin.register Post, as: "EmptyDigitsPost" do
514
+ # :empty behind a sanitizer that can empty the value: the verdict must survive the
515
+ # canonicalizing redirect, or the next request renders the whole table.
516
+ filter :position_eq, as: :string,
517
+ extensions: { sanitize: :digits, validate: :integer, on_invalid: :empty }
518
+ end
519
+
520
+ ActiveAdmin.register Post, as: "EmptyDigitsArrayPost" do
521
+ # A genuine multi-value filter: unlike the scalar filter above, its check boxes submit
522
+ # q[status_in][] and can therefore render the preserved array back to the admin.
523
+ filter :status, as: :check_boxes,
524
+ collection: [["Not a number", "abc"], "draft", "published"],
525
+ extensions: { sanitize: :digits, validate: :integer, on_invalid: :empty }
526
+ end
527
+
528
+ ActiveAdmin.register Post, as: "RejectWindowPost" do
529
+ # Value AND window policies on one :reject filter: the second violation must not answer
530
+ # an already-answered request.
531
+ filter :published_date, as: :date_range,
532
+ extensions: { max_range: 30.days, on_invalid: :reject,
533
+ validate: ->(value) { value.include?("X") && "No X dates." } }
534
+ end
535
+
536
+ ActiveAdmin.register Post, as: "BananaSanitizePost" do
537
+ # Its sanitizer grows the value on every pass - the suite proves that raises instead of
538
+ # redirect-looping forever.
539
+ filter :title_cont, as: :string, extensions: { sanitize: ->(value) { "%\#{value}%" } }
540
+ end
541
+
542
+ ActiveAdmin.register Post, as: "BananaShrinkingDefaultPost" do
543
+ # Its sanitizer trims one hyphen per pass - the default must face the same fixpoint
544
+ # check submitted values do, or the formats search different values without a word.
545
+ filter :title_cont, as: :string,
546
+ extensions: { default: "--keep",
547
+ sanitize: ->(value) { value.sub(/\\A-/, "") } }
548
+ end
549
+
550
+ ActiveAdmin.register Post, as: "FrozenDefaultPost" do
551
+ # A frozen default through an in-place sanitizer: the config's string is not the
552
+ # sanitizer's to mutate - FrozenError on the first visit, or a quietly corrupted config.
553
+ filter :title_cont, as: :string,
554
+ extensions: { default: "ke-ep".freeze,
555
+ sanitize: ->(value) { value.delete!("-") || value } }
556
+ end
557
+
558
+ ActiveAdmin.register Post, as: "BananaMutatingSanitizePost" do
559
+ # Its sanitizer rewrites the value IN PLACE and returns it - compared against itself,
560
+ # the no-fixpoint guard would wave the loop through.
561
+ filter :title_cont, as: :string, extensions: { sanitize: ->(value) { value << "%" } }
562
+ end
563
+
564
+ ActiveAdmin.register Post, as: "EmptyHashDefaultPost" do
565
+ filter :status, as: :select, collection: %w[draft published],
566
+ extensions: { default: { in: -> { {} } } }
567
+ end
568
+
569
+ ActiveAdmin.register Post, as: "IpGuardedPost" do
570
+ # The built-in validator with the app's own words - no lambda duplication just to keep a
571
+ # legacy message.
572
+ filter :title_cont, as: :string, extensions: { validate: :ip, message: "Allowed IP address is incorrect!" }
573
+ end
574
+
575
+ ActiveAdmin.register Post, as: "BananaMessagePost" do
576
+ # Its validator answers a Symbol no locale defines - the generic message must speak,
577
+ # not a translation-missing trace.
578
+ filter :title_cont, as: :string,
579
+ extensions: { validate: ->(value) { value.match?(/\\d/) && :bogus_key } }
580
+ end
581
+
582
+ ActiveAdmin.register Post, as: "ParameterizedSymbolPost" do
583
+ # A validate: answering one of the gem's own parameterized symbols - which the docs
584
+ # invite - with nothing to interpolate %{limit} from.
585
+ filter :title_cont, as: :string,
586
+ extensions: { validate: ->(value) { value.match?(/\\d/) && :range_too_wide } }
587
+ end
588
+
589
+ ActiveAdmin.register Post, as: "InterpolatedMessagePost" do
590
+ # message: in the app's own words, naming the gem's own interpolation.
591
+ filter :published_date, as: :date_range,
592
+ extensions: { max_range: 90.days,
593
+ message: "Pick at most %{limit}." }
594
+ end
595
+
596
+ ActiveAdmin.register Post, as: "BananaNoticeKeyPost" do
597
+ # require_any_filter named with a Symbol no locale defines.
598
+ require_any_filter notice: :no_such_notice_key
599
+
600
+ filter :title_cont, as: :string
601
+ end
602
+
603
+ ActiveAdmin.register Post, as: "NoDefaultsPost" do
604
+ filter :title
605
+ end
606
+
607
+ ActiveAdmin.register Post, as: "CollidingNoticePost" do
608
+ # The notice on its DEFAULT flash key - the same :notice a batch action writes. Both have
609
+ # to coexist on the redirect: the arriving message wins, it names what just happened.
610
+ default_filters_notice "Showing published posts by default"
611
+
612
+ filter :status, as: :select, collection: %w[draft published], extensions: { default: "published" }
613
+
614
+ collection_action :poke do
615
+ redirect_to collection_path, notice: "poked"
616
+ end
617
+ end
618
+
619
+ ActiveAdmin.register Post, as: "NoticePost" do
620
+ # Its own flash key, the way an app keeps the filters notice from colliding with the
621
+ # :notice a batch action or callback writes - both have to survive the redirect together.
622
+ default_filters_notice "Showing published posts by default", flash_key: :filters_notice
623
+
624
+ filter :status, as: :select, collection: %w[draft published], extensions: { default: "published" }
625
+
626
+ collection_action :poke do
627
+ redirect_to collection_path, notice: "poked"
628
+ end
629
+ end
630
+ RUBY
631
+
632
+ run "rm -rf test"
633
+ route "root to: 'admin/dashboard#index'"
634
+ rake "db:migrate"
635
+
636
+ # Removed last so the generators above still resolve their dependencies.
637
+ run "rm -f Gemfile Gemfile.lock"
data/tasks/test.rake ADDED
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ desc "Creates a test rails app for the specs to run against"
4
+ task :setup do
5
+ require "rails/version"
6
+
7
+ # The template throws the generated Gemfile away, so the app runs under this gem's sprockets
8
+ # bundle with no importmap-rails. Without --skip-javascript, Rails 8.1 writes
9
+ # `stale_when_importmap_changes` into ApplicationController and booting it raises NameError.
10
+ args = %w[
11
+ --skip-spring
12
+ --skip-bootsnap
13
+ --skip-test
14
+ --skip-system-test
15
+ --skip-javascript
16
+ -m spec/support/rails_template.rb
17
+ ].join(" ")
18
+
19
+ app_dir = "spec/rails/rails-#{Rails::VERSION::STRING}"
20
+ # From scratch, always: `rails new` git-inits the app, so a nested repo survives
21
+ # `git clean -fdx` and the generator would then write into the surviving tree - leaving a
22
+ # stale resource behind a freshly stamped digest, which is a green the stamp exists to deny.
23
+ require "fileutils"
24
+ FileUtils.rm_rf(app_dir)
25
+ sh "bundle exec rails new #{app_dir} #{args}"
26
+
27
+ # Stamped so rails_helper can refuse an app generated from an older template instead of
28
+ # silently testing against resources the template no longer declares.
29
+ require "digest"
30
+ File.write(File.join(app_dir, ".rails_template_digest"),
31
+ Digest::SHA256.file("spec/support/rails_template.rb").hexdigest)
32
+ end