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
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
data/.rspec
ADDED
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
|