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
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: '0443509d93e7b99ed2bc83008c9e5fffe5549792149739800b4f333c33a0636e'
4
+ data.tar.gz: c656901ee3a0f18e865e9653d34661a6eca3be4bc4dd0cb551c1521cf7f30bd9
5
+ SHA512:
6
+ metadata.gz: 25d494262038dafecea4a3fecba7f7b71f718338392d434b74645f8ca3e9fb81682653a84f383b4ed294530fbc90f5b73086d942a318526dae92d8c8e09f2347
7
+ data.tar.gz: 3a0ab0e4b77ce5465ccfec957a767fce7ef67a51e166270adae9fb70f3e8cc7671634b7b25710816d7b0dc18b3c18b23f3776fc27bb9b67c1b78236efc13eccc
@@ -0,0 +1,42 @@
1
+ name: CI
2
+
3
+ on:
4
+ pull_request:
5
+ push:
6
+ branches: [main]
7
+
8
+ permissions:
9
+ contents: read
10
+
11
+ jobs:
12
+ test:
13
+ name: Ruby ${{ matrix.ruby }} / Rails ${{ matrix.rails }} / AA ${{ matrix.activeadmin }}
14
+ runs-on: ubuntu-latest
15
+ strategy:
16
+ fail-fast: false
17
+ matrix:
18
+ # Rails 7.2 reached end of life on 2026-08-09, so the floor is 8.0. Ruby 3.2 went EOL
19
+ # on 2026-03-31, so the floor there is 3.3 (security-only until 2027-03-31).
20
+ ruby: ['3.3', '3.4', '4.0']
21
+ rails: ['8.0.0', '8.1.0']
22
+ activeadmin: ['3.5.0']
23
+ env:
24
+ RAILS: ${{ matrix.rails }}
25
+ AA: ${{ matrix.activeadmin }}
26
+ steps:
27
+ - uses: actions/checkout@v4
28
+
29
+ - uses: ruby/setup-ruby@v1
30
+ with:
31
+ ruby-version: ${{ matrix.ruby }}
32
+ bundler-cache: true
33
+
34
+ # spec/rails_helper.rb would do this on its own, but running it as a step of its own keeps
35
+ # a failure in app generation from being reported as a failure in the first example.
36
+ - name: Generate the sample app
37
+ run: bundle exec rake setup
38
+
39
+ # The Clear Filters example drives a real browser through cuprite; ubuntu-latest ships
40
+ # Chrome, so there is nothing to install.
41
+ - name: Run tests
42
+ run: bundle exec rake spec
data/.gitignore ADDED
@@ -0,0 +1,7 @@
1
+ *.gem
2
+ /.bundle/
3
+ /.worktrees/
4
+ /pkg/
5
+ /spec/rails/
6
+ /tmp/
7
+ Gemfile.lock
data/.rspec ADDED
@@ -0,0 +1,3 @@
1
+ --require spec_helper
2
+ --color
3
+ --exclude-pattern "rails/**/*_spec.rb"
data/Gemfile ADDED
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ source "https://rubygems.org"
4
+
5
+ gemspec
6
+
7
+ default_rails_version = "8.0.0"
8
+ default_activeadmin_version = "3.5.0"
9
+
10
+ gem "rails", "~> #{ENV['RAILS'] || default_rails_version}"
11
+ gem "activeadmin", "~> #{ENV['AA'] || default_activeadmin_version}"
12
+ gem "sprockets-rails"
13
+ gem "sass-rails"
14
+
15
+ group :test do
16
+ gem "capybara"
17
+ gem "cuprite"
18
+ gem "database_cleaner-active_record"
19
+ gem "rspec-rails"
20
+ gem "sqlite3", "~> 2.0"
21
+ gem "webrick", require: false
22
+ end
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Igor Fedoronchuk <fedoronchuk@gmail.com>
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,357 @@
1
+ # active_admin_filter_extensions
2
+
3
+ Declarative extensions for Active Admin index filters: defaults, mandatory windows, value
4
+ policies and honest URLs - one `extensions:` option on the filter instead of a hand-rolled
5
+ `before_action` mutating `params[:q]`.
6
+
7
+ ```ruby
8
+ filter :created_at, as: :date_time_range,
9
+ extensions: { required: { unless: %i[id_eq uuid_eq] }, # the window every request gets...
10
+ default: -> { 1.day.ago.beginning_of_day.. }, # ...and what it defaults to
11
+ max_range: 90.days } # wider is refused, loudly
12
+ ```
13
+
14
+ **What ships:**
15
+
16
+ * [`default:`](#usage) - the filter's default value: scalar, Range, Hash of predicates, or a
17
+ per-request Proc; applied to a bare visit, absent from one the admin filtered themselves.
18
+ * [`required:`](#required-filters-the-mandatory-window) - the mandatory window: the default
19
+ applies whenever the filter's keys are absent, whatever else the request carries;
20
+ `{ if:/unless: key(s) }` names the searches that switch it on or off.
21
+ * [`with:`](#filters-that-only-make-sense-together) - all-or-none groups (year with month):
22
+ half an answer resets to the defaults with a warning.
23
+ * [`sanitize:` / `validate:`](#value-policies-sanitize-and-validate) - strip what typing drags
24
+ in, refuse what the search cannot mean; built-ins `:digits`, `:integer`, `:ip`.
25
+ * [`on_invalid:`](#on_invalid-what-a-violated-policy-does) - what a violation does: `:reset`
26
+ (drop + refill the default), `:reject` (bounce back), `:empty` (render nothing, keep the input).
27
+ * [`max_range:` / `min:`](#window-policies-max_range-and-min) - range caps and retention
28
+ floors on two-ended filters.
29
+ * [`message:`](#value-policies-sanitize-and-validate) and plain-i18n defaults for every
30
+ violation text.
31
+ * [`default_filters_notice`](#telling-the-admin-why) - the flash that tells the admin why the
32
+ page shows a slice; [`require_any_filter`](#resource-level-guard) - an index that waits to
33
+ be asked.
34
+ * [The redirect](#the-redirect) - a bare HTML visit answers with one 302 to itself with the
35
+ effective filters spelled out, so a copied URL shows the same page to whoever opens it.
36
+
37
+ Some index pages are not "a list you may want to narrow down", they are "a list that is only
38
+ meaningful, or only affordable, inside a window": a log table partitioned by date, a page backed
39
+ by an external service that will not answer an unbounded query, a queue an agent should land on.
40
+ Active Admin has no way to say so, so every app of this size grows a `before_action` that mutates
41
+ `params[:q]`. This is that, as options on the filter itself.
42
+
43
+ ```ruby
44
+ ActiveAdmin.register Order do
45
+ filter :state_eq, as: :select, collection: %w[pending paid], extensions: { default: "pending" }
46
+ end
47
+ ```
48
+
49
+ ## Install
50
+
51
+ ```ruby
52
+ gem "active_admin_filter_extensions", "~> 1.0"
53
+ ```
54
+
55
+ The gem is published on RubyGems.
56
+
57
+ ## The extensions: container
58
+
59
+ Everything this gem adds lives under one filter option, so its names can never collide with
60
+ Formtastic's. A gem key left flat raises rather than quietly meaning something else:
61
+
62
+ ```ruby
63
+ filter :title_cont, as: :string, extensions: { default: "acme" } # right
64
+ filter :title_cont, as: :string, default: "acme" # raises
65
+ ```
66
+
67
+ With one carve-out: `required:` and `min:` are Formtastic input options in their own right, so
68
+ flat they belong to the form and pass through untouched. This gem answers to them only inside
69
+ `extensions:`.
70
+
71
+ ```ruby
72
+ filter :position, as: :number, min: 0 # Formtastic's - renders min="0"
73
+ filter :created_at, as: :date_range, extensions: { min: -> { 2.months.ago } } # the gem's floor
74
+ ```
75
+
76
+ ## Usage
77
+
78
+ A default is a **value**. Which Ransack key it lands under is the input's business, and the
79
+ input is asked - so a `:select` gets `_eq`, a `:check_boxes` gets `_in` under the association's
80
+ primary key, and a `:string` gets whichever predicate heads its dropdown, including when the
81
+ resource or the namespace has reordered that list:
82
+
83
+ ```ruby
84
+ filter :status, as: :select, extensions: { default: "active" }
85
+ filter :author, as: :check_boxes, extensions: { default: [1, 2] }
86
+ filter :created_at, as: :date_range, extensions: { default: -> { 1.week.ago..Time.current } }
87
+ filter :created_at, as: :date_range, extensions: { default: -> { 1.week.ago.. } } # lower bound only
88
+ filter :title, extensions: { default: "acme" }
89
+ ```
90
+
91
+ **A Range** fills a two-ended input, one bound per end; leave an end off and that end is left to
92
+ the admin. Handing a single value to a two-ended input raises, rather than picking an end for you.
93
+ So does an exclusive range (`a...b`): the input submits `gteq` and `lteq`, so the excluded end has
94
+ no spelling there and would be searched after all.
95
+
96
+ Anything relative to now belongs in a Proc, as above. `filter` runs when the resource file is
97
+ loaded, so `default: 1.week.ago..` would pin the window to the moment the process booted and let
98
+ it drift for as long as that process lives - quietly, and worst on the long-running ones. A Proc
99
+ is re-read on every request.
100
+
101
+ **A Proc** is evaluated against the controller on every request, and a `nil` result applies no
102
+ filter, which is how a default is made conditional or read off the signed-in admin:
103
+
104
+ ```ruby
105
+ filter :author_id_eq, extensions: { default: -> { current_admin_user.id unless current_admin_user.admin? } }
106
+ filter :queue_eq, extensions: { default: -> { current_admin_user.default_queue } }
107
+ ```
108
+
109
+ Asking the input means you get whatever that input actually submits, which is not always the
110
+ predicate the docs of some other app would lead you to expect. A `:string` filter submits the
111
+ head of its dropdown, and an app that re-registers Ransack's aliases - `contains`, `equals`,
112
+ `starts_with` - reorders that list, so `default: "acme"` may search for equality rather than a
113
+ substring. That is correct, since it is what an untouched form submits there, but check it
114
+ rather than assume.
115
+
116
+ Deriving needs a resource Ransack can search. A resource backed by something else - an
117
+ ActiveResource model standing in for an HTTP API, say - has no `ransack`, and the derivation
118
+ raises rather than guessing:
119
+
120
+ filter :created_at declares a `default:` but could not work out which search key it submits
121
+ (NoMethodError: undefined method 'ransack' ...) - name the predicate with a Hash instead
122
+
123
+ **A Hash** names the predicates outright, for that case and for when the input's own predicate
124
+ is not the one you want:
125
+
126
+ ```ruby
127
+ filter :title, extensions: { default: { eq: "acme" } } # rather than the _cont it would submit
128
+ filter :created_at, as: :date_range, extensions: { default: { gteq: -> { 1.week.ago } } }
129
+ ```
130
+
131
+ A filter hidden by `:if` or `:unless` imposes no default either — it would filter the collection
132
+ with no control on screen to undo it.
133
+
134
+ ## Getting out of a default
135
+
136
+ Defaults apply only while the request carries no filters of its own.
137
+
138
+ * The default also seeds the visible form, so the admin can see why the list is filtered and edit
139
+ that value like any other filter.
140
+ * **Blank the field and press Filter** to see the unfiltered collection. Active Admin disables
141
+ empty fields on submit, so a blank submission sends no `q` at all and is told apart from a first
142
+ visit by `commit`.
143
+ * **Clear Filters** drops `commit` along with `q`, so it returns the page to its defaults rather
144
+ than to an empty filter set.
145
+
146
+ ## Widening when the defaults apply
147
+
148
+ By default they apply to a request that carries no filters of its own. A resource that always
149
+ carries something in `q` — a customer id pinned by a nested route, say — can widen that:
150
+
151
+ ```ruby
152
+ controller do
153
+ def filter_defaults_apply?
154
+ super || params[:q].keys == %w[customer_id_eq]
155
+ end
156
+ end
157
+ ```
158
+
159
+ Whatever the request asked for wins over a default, so the pinned value survives.
160
+
161
+ ## Telling the admin why
162
+
163
+ An index that quietly shows a slice of the table owes the admin a word about it:
164
+
165
+ ```ruby
166
+ ActiveAdmin.register Export do
167
+ default_filters_notice "Records for the last month are displayed by default"
168
+ filter :created_at, as: :date_range, extensions: { default: { gteq: -> { 1.month.ago } } }
169
+ end
170
+ ```
171
+
172
+ The message is flashed only when the defaults actually took effect. Pass `flash_key:` to use
173
+ something other than `:notice`.
174
+
175
+ ## Required filters: the mandatory window
176
+
177
+ Some tables must never be queried without a bound - a log partitioned by date, a CDR table
178
+ where an unbounded scan is an outage. `required: true` promotes the filter's default from
179
+ "applies to a bare request" to "applies whenever THIS filter's keys are absent", whatever
180
+ else the request carries - and a blank filters-form submission gets the window back rather
181
+ than the whole table:
182
+
183
+ ```ruby
184
+ filter :time_start, as: :date_time_range,
185
+ extensions: { required: { unless: %i[id_eq uuid_eq] }, # a search naming one record escapes it
186
+ default: -> { 1.day.ago.beginning_of_day..Date.tomorrow.end_of_day } }
187
+ ```
188
+
189
+ `required: true` is unconditional. A Hash narrows it: `{ unless: keys }` excuses the window
190
+ beside those keys, `{ if: keys }` imposes it only beside those keys; both take a Symbol or an
191
+ Array and combine.
192
+
193
+ The required window is group-wide: a request that touched ANY of the filter's own keys has
194
+ answered it - an upper bound alone is honored as given, never completed from the default
195
+ into a pair the admin did not mean. What a half-open range may not mean is `max_range:`'s
196
+ business. `required:` without a `default:` raises on the first index request: mandatory with nothing
197
+ to fall back on is a configuration error, not a quiet empty page. The defaults notice
198
+ speaks whenever a default actually reached the search - including onto a partly-filtered
199
+ request.
200
+
201
+ ## What the policies do and do not guard
202
+
203
+ A value policy watches every request key that names its filter's attribute - the derived
204
+ head key and the sibling predicates a dropdown submits (`position_gt` beside a policed
205
+ `position_eq`). The deliberate boundaries:
206
+
207
+ * **Window, required and with: groups watch their derived and named keys** - not arbitrary
208
+ predicates of other attributes. Policies are guard rails for the UI the filter renders -
209
+ for hostile-proof filtering, whitelist at the model
210
+ (`ransackable_attributes`/`ransackable_scopes`), which is also where Ransack itself draws
211
+ the trust line.
212
+ * **A policed key refuses non-string shapes.** `?q[title_cont][value]=x` executes
213
+ byte-identically to the scalar, so a Hash- or Array-wrapped value on a policed key is
214
+ treated as a violation, not ignored.
215
+ * **A `required:` filter whose Proc default resolves to nil imposes nothing for that
216
+ request.** That is how a per-admin default opts out ("no COMMERCIAL narrowing for this
217
+ admin"); for a window that must never be absent, return a window from every branch of the
218
+ Proc.
219
+ * **`sanitize:` must be idempotent** - sanitizing its own output must change nothing. In the
220
+ redirect the sanitized value comes back in the URL for another pass; a sanitizer
221
+ that keeps rewriting is an infinite redirect, and the gem raises on the first request
222
+ instead of looping.
223
+
224
+ ## Filters that only make sense together
225
+
226
+ ```ruby
227
+ filter :month, as: :select, collection: 1..12,
228
+ extensions: { default: -> { Date.current.month }, with: :year_eq }
229
+ ```
230
+
231
+ A filter declared `with:` its partner key(s) - a Symbol or an Array - forms one group out of
232
+ its own search keys and the partners: either every key of the group is answered or none. A
233
+ half-answered group is reset to the declared defaults with a warning, whichever side the half
234
+ came from. Declare it on either member; mirrored declarations dedupe to the one group they
235
+ both name.
236
+
237
+ ## Resource-level guard
238
+
239
+ ```ruby
240
+ require_any_filter notice: "You must specify at least one filter"
241
+ ```
242
+
243
+ `require_any_filter` makes an index answer no question until it is asked one: a bare visit -
244
+ a blank form submission included - renders an empty collection with the notice, never the
245
+ whole table. Resource-level on purpose: "any filter at all" belongs to no single filter. A
246
+ scope tab counts as a question: Active Admin puts a count on it, and answering it with an empty
247
+ page would contradict the tab.
248
+
249
+ ## on_invalid: what a violated policy does
250
+
251
+ Three named consequences, because the inventoried apps genuinely used them:
252
+
253
+ ```ruby
254
+ filter :time_start, ..., extensions: { max_range: 90.days } # :reset is the default
255
+ filter :customer_id, extensions: { validate: ->(v) { :invalid unless v.match?(/\A\d+\z/) }, on_invalid: :reject }
256
+ filter :name_eq, extensions: { validate: ->(v) { ... }, on_invalid: :empty }
257
+ ```
258
+
259
+ * `:reset` - drop the offending key(s), `flash[:error]`, the declared `default:` refills.
260
+ * `:reject` - `flash[:error]` and bounce back the way the request came (bare index when
261
+ there is no referer). Off the HTML path - a CSV or JSON index - it degrades to `:reset`:
262
+ there is nowhere to bounce to and no flash to read.
263
+ * `:empty` - render an empty collection with the message, keeping the admin's input in the
264
+ form: "nothing found because the value is malformed" stops masquerading as an honestly
265
+ empty result.
266
+
267
+ The URL always ends up telling the truth: the redirect compares the
268
+ search that will actually run against the query string as the browser sent it, so a value a
269
+ policy rewrote (sanitize) or dropped redirects once to the corrected address.
270
+
271
+ ## Window policies: max_range and min
272
+
273
+ The range caps and retention floors, declared where the window is:
274
+
275
+ ```ruby
276
+ filter :time_start, as: :date_time_range,
277
+ extensions: { required: true,
278
+ default: -> { 1.day.ago.beginning_of_day..Date.tomorrow.end_of_day },
279
+ max_range: 90.days,
280
+ min: -> { 2.months.ago } }
281
+ ```
282
+
283
+ A violation - unparseable bound, inverted range, wider than `max_range:`, earlier than
284
+ `min:` - resets the PAIR: both bounds dropped, one `flash[:error]`, the declared default
285
+ refills. An open upper bound is measured against now; a FUTURE lower bound with no upper
286
+ bound selects a not-yet-written window and passes. A default that would itself violate the
287
+ policy raises on first use - resetting into a refused window is a loop, not a fallback.
288
+ Messages are i18n keys under `active_admin_filter_extensions.*` (`range_invalid`,
289
+ `range_too_wide` with `%{limit}` (the cap in the units it was declared in, "90 days" or
290
+ "6 hours"), `before_floor` with `%{floor}`), shipped with English
291
+ defaults.
292
+
293
+ ## Value policies: sanitize and validate
294
+
295
+ The guards every app of this size hand-rolls in index before_actions - strip what typing
296
+ drags in, refuse what the search cannot mean - as options on the filter itself:
297
+
298
+ ```ruby
299
+ filter :ip_contains, as: :string,
300
+ extensions: { sanitize: ->(value) { value.sub(%r{/+\z}, "") },
301
+ validate: :ip } # built-in; or a callable
302
+
303
+ filter :title_cont, as: :string,
304
+ extensions: { default: "acme",
305
+ validate: ->(value) { "Title must not contain digits." if value.match?(/\d/) } }
306
+
307
+ filter :id_eq, as: :string,
308
+ extensions: { sanitize: :digits, # built-in: strip to digits
309
+ validate: :integer } # built-in: must fit a PG int4
310
+ ```
311
+
312
+ `sanitize:` rewrites the value in place before anything reads it - including the redirect
313
+ decision, so the URL carries what was actually searched.
314
+ `validate:` answers nil for a good value, or a message: a String used verbatim, or a Symbol
315
+ looked up under the `active_admin_filter_extensions.*` i18n scope with `%{filter}` available.
316
+
317
+ On a violation the policy is `:reset`: the offending key is dropped from `q`, the message
318
+ lands in `flash[:error]` (and rides the redirect), and the filter's declared `default:` - if
319
+ any - refills through the ordinary merge. The defaults notice stays quiet on that render:
320
+ the error already explains why the page shows the default slice.
321
+
322
+ ## The redirect
323
+
324
+ A bare visit to the index redirects once to itself with the effective filters spelled out in
325
+ the query string. A relative default is frozen to the dates it came to, a per-admin default
326
+ to the values this admin saw - so the address the admin copies shows the same page to whoever
327
+ opens it, instead of re-resolving the defaults against the recipient's clock and account. A
328
+ declared `default_filters_notice` rides the redirect. The price: +1 redirect on every bare
329
+ visit, and a bookmarked URL pins the defaults of the day it was made rather than following
330
+ the code. Non-HTML requests (CSV, JSON) are never redirected; the defaults filter them all
331
+ the same.
332
+
333
+ Four prints in small type:
334
+
335
+ * **Code that rewrites `q` in a `before_action`** (sanitizers that delete invalid ranges,
336
+ say) runs after the redirect has already compared the request against the defaults - the
337
+ URL then keeps saying what the sanitizer removed. Rewrite `q` before the gem sees it, or
338
+ declare the rule as a `sanitize:`/`validate:` extension and let the redirect carry the
339
+ corrected value.
340
+ * **The redirect runs after authentication but before Active Admin's index authorization**,
341
+ so an authenticated admin without read rights on a resource still receives the 302 whose
342
+ Location spells out that resource's defaults. Keep per-role secrets out of defaults.
343
+ * **Ransack's structural keys are stripped on resources that declare something of this
344
+ gem's.** `q[m]`, `q[g]` and `q[c]` turn a required, capped window into `window OR anything`
345
+ and nest conditions no value policy ever sees, so where a policy guards the filters they
346
+ are a bypass rather than an answer. A resource that declares no extension keeps them - the
347
+ hand-built `q[g]` OR link is the standard Active Admin workaround. Sorting (`q[s]`) is
348
+ never a condition and is never touched.
349
+ * **Anything that reads `params[:q]` for the effective filters** (drill-down links, exports)
350
+ should read `filtering_params` instead - on the rendered page they agree, but only
351
+ `filtering_params` is also right on the non-redirected formats.
352
+
353
+ Tested against Active Admin 3.5 on Rails 8.0 and 8.1, under Ruby 3.3, 3.4 and 4.0.
354
+
355
+ ## License
356
+
357
+ MIT
data/Rakefile ADDED
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ Dir["tasks/**/*.rake"].each { |task| load task }
7
+
8
+ RSpec::Core::RakeTask.new(:unit) do |t|
9
+ t.pattern = "spec/*_spec.rb"
10
+ end
11
+
12
+ desc "Run the integration suite against the generated sample app"
13
+ task :integration do
14
+ sh "bundle exec rspec spec/integration"
15
+ end
16
+
17
+ task spec: %i[unit integration]
18
+ task default: :spec
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "lib/active_admin_filter_extensions/version"
4
+
5
+ Gem::Specification.new do |s|
6
+ s.name = "active_admin_filter_extensions"
7
+ s.version = ActiveAdminFilterExtensions::VERSION
8
+ s.authors = ["Igor Fedoronchuk"]
9
+ s.email = ["fedoronchuk@gmail.com"]
10
+ s.homepage = "https://github.com/activeadmin-plugins/active_admin_filter_extensions"
11
+ s.license = "MIT"
12
+ s.summary = "Defaults and value policies for Active Admin index filters"
13
+ s.description = "Lets an Active Admin resource declare its index filter defaults and the " \
14
+ "policies that guard them - required windows, range caps, sanitizers and " \
15
+ "validators - and a :redirect mode that spells the effective filters into " \
16
+ "the URL."
17
+
18
+ s.required_ruby_version = ">= 3.3"
19
+ s.add_dependency "activeadmin", ">= 3.5", "< 4"
20
+
21
+ s.files = `git ls-files`.split("\n")
22
+ s.require_paths = ["lib"]
23
+
24
+ s.metadata = {
25
+ "homepage_uri" => s.homepage,
26
+ "source_code_uri" => s.homepage,
27
+ "rubygems_mfa_required" => "true"
28
+ }
29
+ end
@@ -0,0 +1,10 @@
1
+ # The gem's violation messages. Override any of them in your application's locale files
2
+ # under the same keys; `message:` on a filter overrides per filter.
3
+ en:
4
+ active_admin_filter_extensions:
5
+ invalid: "%{filter} filter has an invalid value."
6
+ range_invalid: "%{filter} filter has an invalid date range value."
7
+ range_too_wide: "%{filter} filter allows a maximum date range of %{limit}."
8
+ before_floor: "%{filter} filter cannot start before %{floor}."
9
+ given_together: "%{filters} filters must be given together."
10
+ require_any_filter: "You must specify at least one filter"
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveAdminFilterExtensions
4
+ # The single method this gem replaces: Active Admin searches the collection on `params[:q]`
5
+ # read directly, and it searches #filtering_params instead. Everything that method means is
6
+ # in FilterDefaults.
7
+ #
8
+ # The Ransack call is the Active Admin 3 one, which this gem targets: Active Admin 4 passes
9
+ # `auth_object: active_admin_authorization` there as well.
10
+ module DataAccess
11
+ protected
12
+
13
+ def apply_filtering(chain)
14
+ @search = chain.ransack(filtering_params)
15
+ # An :empty policy keeps the request intact - the form still shows what the admin
16
+ # typed - and blanks the collection instead.
17
+ return @search.result.none if filter_policy_render_empty?
18
+
19
+ @search.result
20
+ end
21
+ end
22
+ end