active_admin_filter_extensions 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.github/workflows/ci.yml +42 -0
- data/.gitignore +7 -0
- data/.rspec +3 -0
- data/Gemfile +22 -0
- data/LICENSE +21 -0
- data/README.md +357 -0
- data/Rakefile +18 -0
- data/active_admin_filter_extensions.gemspec +29 -0
- data/config/locales/en.yml +10 -0
- data/lib/active_admin_filter_extensions/data_access.rb +22 -0
- data/lib/active_admin_filter_extensions/filter_defaults.rb +327 -0
- data/lib/active_admin_filter_extensions/filter_options.rb +70 -0
- data/lib/active_admin_filter_extensions/filters_form.rb +47 -0
- data/lib/active_admin_filter_extensions/notice.rb +38 -0
- data/lib/active_admin_filter_extensions/policies.rb +539 -0
- data/lib/active_admin_filter_extensions/redirect.rb +82 -0
- data/lib/active_admin_filter_extensions/required_filters.rb +83 -0
- data/lib/active_admin_filter_extensions/search_keys.rb +71 -0
- data/lib/active_admin_filter_extensions/version.rb +5 -0
- data/lib/active_admin_filter_extensions.rb +46 -0
- data/spec/data_access_spec.rb +252 -0
- data/spec/integration/extensions_container_spec.rb +38 -0
- data/spec/integration/filter_defaults_spec.rb +229 -0
- data/spec/integration/numeric_builtins_spec.rb +104 -0
- data/spec/integration/on_invalid_spec.rb +174 -0
- data/spec/integration/policies_spec.rb +204 -0
- data/spec/integration/redirect_spec.rb +204 -0
- data/spec/integration/require_any_filter_spec.rb +132 -0
- data/spec/integration/required_spec.rb +155 -0
- data/spec/integration/structural_keys_spec.rb +77 -0
- data/spec/integration/window_spec.rb +208 -0
- data/spec/integration/with_spec.rb +175 -0
- data/spec/rails_helper.rb +62 -0
- data/spec/redirect_install_spec.rb +27 -0
- data/spec/spec_helper.rb +68 -0
- data/spec/support/capybara_driver.rb +12 -0
- data/spec/support/rails_template.rb +637 -0
- data/tasks/test.rake +32 -0
- metadata +103 -0
|
@@ -0,0 +1,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
|