active_admin_filter_extensions 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. checksums.yaml +7 -0
  2. data/.github/workflows/ci.yml +42 -0
  3. data/.gitignore +7 -0
  4. data/.rspec +3 -0
  5. data/Gemfile +22 -0
  6. data/LICENSE +21 -0
  7. data/README.md +357 -0
  8. data/Rakefile +18 -0
  9. data/active_admin_filter_extensions.gemspec +29 -0
  10. data/config/locales/en.yml +10 -0
  11. data/lib/active_admin_filter_extensions/data_access.rb +22 -0
  12. data/lib/active_admin_filter_extensions/filter_defaults.rb +327 -0
  13. data/lib/active_admin_filter_extensions/filter_options.rb +70 -0
  14. data/lib/active_admin_filter_extensions/filters_form.rb +47 -0
  15. data/lib/active_admin_filter_extensions/notice.rb +38 -0
  16. data/lib/active_admin_filter_extensions/policies.rb +539 -0
  17. data/lib/active_admin_filter_extensions/redirect.rb +82 -0
  18. data/lib/active_admin_filter_extensions/required_filters.rb +83 -0
  19. data/lib/active_admin_filter_extensions/search_keys.rb +71 -0
  20. data/lib/active_admin_filter_extensions/version.rb +5 -0
  21. data/lib/active_admin_filter_extensions.rb +46 -0
  22. data/spec/data_access_spec.rb +252 -0
  23. data/spec/integration/extensions_container_spec.rb +38 -0
  24. data/spec/integration/filter_defaults_spec.rb +229 -0
  25. data/spec/integration/numeric_builtins_spec.rb +104 -0
  26. data/spec/integration/on_invalid_spec.rb +174 -0
  27. data/spec/integration/policies_spec.rb +204 -0
  28. data/spec/integration/redirect_spec.rb +204 -0
  29. data/spec/integration/require_any_filter_spec.rb +132 -0
  30. data/spec/integration/required_spec.rb +155 -0
  31. data/spec/integration/structural_keys_spec.rb +77 -0
  32. data/spec/integration/window_spec.rb +208 -0
  33. data/spec/integration/with_spec.rb +175 -0
  34. data/spec/rails_helper.rb +62 -0
  35. data/spec/redirect_install_spec.rb +27 -0
  36. data/spec/spec_helper.rb +68 -0
  37. data/spec/support/capybara_driver.rb +12 -0
  38. data/spec/support/rails_template.rb +637 -0
  39. data/tasks/test.rake +32 -0
  40. metadata +103 -0
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveAdminFilterExtensions
4
+ # `extensions: { required: }` - the mandatory window: a filter whose default applies whenever
5
+ # its own keys are absent, whatever else the request carries.
6
+ module RequiredFilters
7
+ protected
8
+
9
+ # The subset of the defaults that `required: true` promotes to mandatory, grouped per
10
+ # filter so each imposes itself all-or-nothing.
11
+ def required_filter_default_values
12
+ @required_filter_default_values ||= visible_filters.each_with_object({}) do |(attribute, options), result|
13
+ next unless options[:required]
14
+ unless options.key?(:default)
15
+ raise ArgumentError,
16
+ "filter :#{attribute} is required: true but declares no default: to fall back on"
17
+ end
18
+
19
+ # One materialization per request: the group shares filter_default_values' already
20
+ # validated (and sanitized) values instead of resolving the Procs a second time.
21
+ filter_default_values
22
+ group = filter_default_values.slice(*(@filter_default_keys || {})[attribute])
23
+ # Answered by ANY of the filter's own submitted keys, not only the ones its default
24
+ # happens to fill: an endless default must not inject a lower bound beside an
25
+ # upper-only request.
26
+ result[attribute] = { defaults: group, keys: policy_search_keys(attribute, options) }
27
+ .merge(required_conditions(attribute, options))
28
+ end
29
+ end
30
+
31
+ # required: true is unconditional. A Hash narrows it: { if: keys } imposes the window only
32
+ # beside those keys, { unless: keys } excuses it beside those - a search pinpointing a
33
+ # record is not a scan to box in. Keys take a Symbol or an Array; both conditions combine.
34
+ def required_conditions(attribute, options)
35
+ return { if: [], unless: [] } unless options[:required].is_a?(Hash)
36
+
37
+ unknown = options[:required].keys - %i[if unless]
38
+ if unknown.any?
39
+ raise ArgumentError, "filter :#{attribute}: required: takes true or " "{ if:/unless: key(s) }, got #{unknown.map(&:inspect).join(', ')}"
40
+ end
41
+
42
+ { if: Array(options[:required][:if]).map(&:to_s),
43
+ unless: Array(options[:required][:unless]).map(&:to_s) }
44
+ end
45
+
46
+ # The required defaults this request has not answered: only the groups NONE of whose own
47
+ # keys the request carries, whose if: keys (if any) the request does carry, and none of
48
+ # whose unless: keys.
49
+ def required_filter_defaults_absent_from(requested)
50
+ required_filter_default_values.each_with_object({}) do |(_attribute, entry), result|
51
+ next if required_group_answered?(requested, entry[:keys])
52
+ next if entry[:if].any? && entry[:if].none? { |key| answered_value?(requested[key]) }
53
+ next if entry[:unless].any? { |key| answered_value?(requested[key]) }
54
+
55
+ result.merge!(entry[:defaults])
56
+ end
57
+ end
58
+
59
+ # Any predicate of the filter's own attribute is an answer about it - the admin picking
60
+ # "equals" on a :string filter submits title_eq where the derived key is title_cont, and
61
+ # completing that from the default searches two conditions on one column. Read exactly the
62
+ # way #any_declared_filter_answered? reads a question: an exact declared key, or a real
63
+ # predicate on the declared attribute - never a predicate-less stem Ransack ignores, which
64
+ # would switch a mandatory window off with a key that searches nothing.
65
+ def required_group_answered?(requested, keys)
66
+ bases = keys.map { |key| strip_ransack_predicate(key) }
67
+
68
+ requested.any? do |key, value|
69
+ next false unless answered_value?(value)
70
+
71
+ key = key.to_s
72
+ next true if keys.include?(key)
73
+
74
+ key != strip_ransack_predicate(key) && bases.include?(strip_ransack_predicate(key))
75
+ end
76
+ end
77
+
78
+ # `false` is a value, not a blank - the rule the normalized request already follows.
79
+ def answered_value?(value)
80
+ value.present? || value == false
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveAdminFilterExtensions
4
+ # Which Ransack key(s) a filter's input actually submits - derived by building the input
5
+ # the way the sidebar would and asking it, so a declared value lands exactly where the
6
+ # form would put it.
7
+ module SearchKeys
8
+ private
9
+
10
+ # Asked against an empty search on purpose: `current_filter` otherwise answers with whatever
11
+ # predicate the current request happens to carry, and a default is about the request that
12
+ # carries none.
13
+ #
14
+ # @return [Array<String>] one key, or two for a range input
15
+ def filter_search_keys(attribute, options)
16
+ input = build_filter_input(attribute, options)
17
+
18
+ names =
19
+ if input.respond_to?(:gt_input_name)
20
+ [input.gt_input_name, input.lt_input_name]
21
+ elsif input.respond_to?(:current_filter) && !input.seems_searchable?
22
+ # `:string` and `:numeric` let the admin pick the predicate from a dropdown, and the
23
+ # head of that list is what an untouched form submits. Unless the filter name already
24
+ # carries a predicate, in which case there is no dropdown - and asking anyway raises,
25
+ # since `current_filter` would go looking for `title_eq_cont`.
26
+ [input.current_filter]
27
+ else
28
+ [input.input_name]
29
+ end
30
+
31
+ names.map { |name| name.to_s[/\Aq\[([^\]]+)\]/, 1] || name.to_s }
32
+ # Narrow on purpose: #policy_search_keys answers an ArgumentError from here by searching
33
+ # the declared filter name instead, so a catch-all would turn any internal failure into a
34
+ # policy quietly guarding the wrong key. A resource with no `ransack`, and an input class
35
+ # that does not exist, are both NameErrors - and they are what "cannot derive" means.
36
+ rescue NameError => e
37
+ raise_filter_default_error(attribute, "could not work out which search key it submits (#{e.class}: #{e.message}) - name the predicate with a Hash instead")
38
+ end
39
+
40
+ def build_filter_input(attribute, options)
41
+ builder = filter_input_builder
42
+ options = filter_input_options(options, builder)
43
+ as = options[:as] || builder.send(:default_input_type, attribute)
44
+ builder.send(:namespaced_input_class, as)
45
+ .new(builder, builder.template, builder.object, :q, attribute, options)
46
+ end
47
+
48
+ # `:input_html` may be a Proc, which the filters form resolves against the view before it
49
+ # builds the input. Handing the Proc over instead is not merely incomplete, it is wrong in
50
+ # a way nothing reports: `Proc#[]` is `call`, so Formtastic asking `input_html[:multiple]`
51
+ # invokes it, gets a truthy Hash back, and a `:select` derives `_in` rather than `_eq`.
52
+ def filter_input_options(options, builder)
53
+ options = FilterOptions.input_options(options)
54
+ return options unless options[:input_html].is_a?(Proc)
55
+
56
+ options.merge(input_html: builder.template.instance_exec(&options[:input_html]))
57
+ end
58
+
59
+ # One per request rather than one per filter: #view_context builds a fresh view class every
60
+ # time it is called, and every defaulted filter would pay for another one.
61
+ def filter_input_builder
62
+ @filter_input_builder ||= ::ActiveAdmin::Filters::FormBuilder.new(
63
+ :q, active_admin_config.resource_class.ransack({}), view_context, {}
64
+ )
65
+ end
66
+
67
+ def raise_filter_default_error(attribute, message)
68
+ raise ArgumentError, "filter :#{attribute} declares a `default:` but #{message}"
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveAdminFilterExtensions
4
+ VERSION = "1.0.0"
5
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "activeadmin"
4
+ require "active_admin_filter_extensions/version"
5
+
6
+ # The violation messages live in plain i18n - config/locales/en.yml here, overridable by the
7
+ # application's own locale files under the same keys.
8
+ I18n.load_path += Dir[File.expand_path("../config/locales/*.yml", __dir__)]
9
+ require "active_admin_filter_extensions/data_access"
10
+ require "active_admin_filter_extensions/filter_options"
11
+ require "active_admin_filter_extensions/search_keys"
12
+ require "active_admin_filter_extensions/required_filters"
13
+ require "active_admin_filter_extensions/filter_defaults"
14
+ require "active_admin_filter_extensions/filters_form"
15
+ require "active_admin_filter_extensions/redirect"
16
+ require "active_admin_filter_extensions/policies"
17
+ require "active_admin_filter_extensions/notice"
18
+
19
+ module ActiveAdminFilterExtensions
20
+ end
21
+
22
+ # `add_filter` stores whatever options it is given without a whitelist, so `default:` needs no
23
+ # registration of its own.
24
+ ActiveAdmin.before_load do |_app|
25
+ # Prepended, because it replaces Active Admin's own #apply_filtering.
26
+ ActiveAdmin::ResourceController.prepend ActiveAdminFilterExtensions::DataAccess
27
+ # Included, because it only adds - and a resource overriding one of its seams in a
28
+ # `controller do` block should win, which is what including gives.
29
+ ActiveAdmin::ResourceController.include ActiveAdminFilterExtensions::FilterOptions
30
+ ActiveAdmin::ResourceController.include ActiveAdminFilterExtensions::SearchKeys
31
+ ActiveAdmin::ResourceController.include ActiveAdminFilterExtensions::RequiredFilters
32
+ ActiveAdmin::ResourceController.include ActiveAdminFilterExtensions::FilterDefaults
33
+ ActiveAdmin::ResourceController.helper_method :filtering_params, :filter_default_values, :visible_filters
34
+
35
+ ActiveAdmin::Resource.prepend ActiveAdminFilterExtensions::ResourceExtension
36
+ ActiveAdmin::ResourceDSL.include ActiveAdminFilterExtensions::Policies::ResourcePolicyDSL
37
+ ActiveAdmin::Resource.include ActiveAdminFilterExtensions::Policies::ResourcePolicySettings
38
+ ActiveAdmin::ResourceDSL.include ActiveAdminFilterExtensions::Notice
39
+ ActiveAdmin::Resource.include ActiveAdminFilterExtensions::NoticeResource
40
+ # Policies before the redirect: a sanitized or reset value must be what the redirect
41
+ # decision - and therefore the URL - is computed from.
42
+ ActiveAdmin::ResourceController.include ActiveAdminFilterExtensions::Policies::Controller
43
+ ActiveAdmin::ResourceController.include ActiveAdminFilterExtensions::Redirect
44
+ ActiveAdmin::Filters::ViewHelper.prepend ActiveAdminFilterExtensions::ViewHelper
45
+ ActiveAdmin::Filters::ActiveSidebar.prepend ActiveAdminFilterExtensions::ActiveSidebar
46
+ end
@@ -0,0 +1,252 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+ require "active_admin_filter_extensions/policies"
5
+
6
+ RSpec.describe ActiveAdminFilterExtensions::DataAccess do
7
+ subject(:searched_with) do
8
+ chain = FakeChain.new
9
+ controller.send(:apply_filtering, chain)
10
+ chain.ransacked_with
11
+ end
12
+
13
+ let(:controller) { FakeController.new(params: params, filters: filters) }
14
+ let(:params) { {} }
15
+
16
+ describe "a scalar default" do
17
+ let(:filters) { { starred_eq: { extensions: { default: true } } } }
18
+
19
+ it "applies to the filter name as it stands" do
20
+ expect(searched_with).to eq("starred_eq" => true)
21
+ end
22
+
23
+ context "when the request carries filters of its own" do
24
+ let(:params) { { q: { "title_cont" => "hello" } } }
25
+
26
+ it "is left out" do
27
+ expect(searched_with).to eq("title_cont" => "hello")
28
+ end
29
+ end
30
+
31
+ context "when the filters form was submitted with every field blank" do
32
+ # Active Admin disables empty fields on submit, so a blank submission sends no `q` at all
33
+ # and is only distinguishable from a first visit by `commit`.
34
+ let(:params) { { commit: "Filter" } }
35
+
36
+ it "is not re-applied, so the admin sees everything" do
37
+ expect(searched_with).to eq({})
38
+ end
39
+ end
40
+ end
41
+
42
+ describe "a q that is not a Hash" do
43
+ let(:filters) { { starred_eq: { extensions: { default: true } } } }
44
+
45
+ # Rack parses `?q=` to an empty String. It is blank, so the defaults apply - and merging
46
+ # them with a String must not be attempted.
47
+ context "when the request carries `?q=`" do
48
+ let(:params) { { q: "" } }
49
+
50
+ it "treats it as absent and applies the defaults" do
51
+ expect(searched_with).to eq("starred_eq" => true)
52
+ end
53
+ end
54
+ end
55
+
56
+ describe "a Hash default" do
57
+ let(:filters) { { created_at: { as: :date_range, extensions: { default: { gteq: "2020-01-01" } } } } }
58
+
59
+ it "keys each value by its predicate" do
60
+ expect(searched_with).to eq("created_at_gteq" => "2020-01-01")
61
+ end
62
+ end
63
+
64
+ describe "an Array default" do
65
+ # `filter :author, as: :check_boxes` submits as `q[author_id_in][]`, so the predicate side
66
+ # of the key is `id_in`, not `in`.
67
+ let(:filters) { { author: { as: :check_boxes, extensions: { default: { id_in: [1, 2] } } } } }
68
+
69
+ it "is keyed the way the input submits it" do
70
+ expect(searched_with).to eq("author_id_in" => [1, 2])
71
+ end
72
+ end
73
+
74
+ describe "a Proc default" do
75
+ let(:filters) { { position_eq: { extensions: { default: -> { params[:seed] } } } } }
76
+ let(:params) { { seed: "7" } }
77
+
78
+ it "is evaluated against the controller" do
79
+ expect(searched_with).to eq("position_eq" => "7")
80
+ end
81
+
82
+ context "when it returns nil" do
83
+ let(:params) { {} }
84
+
85
+ it "applies no filter rather than filtering on NULL" do
86
+ expect(searched_with).to eq({})
87
+ end
88
+ end
89
+
90
+ it "is evaluated once per request, so it may query" do
91
+ calls = 0
92
+ controller = FakeController.new(filters: { title_eq: { extensions: { default: -> { calls += 1; "v" } } } })
93
+ 3.times { controller.send(:filter_default_values) }
94
+ expect(calls).to eq(1)
95
+ end
96
+ end
97
+
98
+ describe "a Symbol default" do
99
+ let(:filters) { { title_eq: { extensions: { default: :hello } } } }
100
+
101
+ it "is a value, not a method to send" do
102
+ expect(searched_with).to eq("title_eq" => :hello)
103
+ end
104
+ end
105
+
106
+ describe ":if and :unless" do
107
+ let(:filters) do
108
+ {
109
+ starred_eq: { extensions: { default: true }, if: -> { false } },
110
+ position_eq: { extensions: { default: 1 }, unless: -> { true } },
111
+ title_eq: { extensions: { default: "kept" }, if: -> { true } }
112
+ }
113
+ end
114
+
115
+ it "only applies the defaults of the filters that are rendered" do
116
+ expect(searched_with).to eq("title_eq" => "kept")
117
+ end
118
+
119
+ it "accepts a Symbol condition and sends it to the controller" do
120
+ controller = FakeController.new(filters: { title_eq: { extensions: { default: "v" }, if: :never } })
121
+ def controller.never = false
122
+ expect(controller.send(:filter_default_values)).to eq({})
123
+ end
124
+ end
125
+
126
+ describe "#filter_defaults_apply?" do
127
+ let(:filters) { { starred_eq: { extensions: { default: true } } } }
128
+
129
+ context "when an override lets defaults through on a partly filtered request" do
130
+ let(:params) { { q: { "customer_id_eq" => "7" } } }
131
+
132
+ before do
133
+ def controller.filter_defaults_apply?
134
+ super || params[:q].keys == %w[customer_id_eq]
135
+ end
136
+ end
137
+
138
+ it "keeps what the request asked for and adds the defaults to it" do
139
+ expect(searched_with).to eq("customer_id_eq" => "7", "starred_eq" => true)
140
+ end
141
+ end
142
+
143
+ context "when the request carries a value for a filter that also declares a default" do
144
+ let(:params) { { q: { "starred_eq" => false } } }
145
+
146
+ before do
147
+ def controller.filter_defaults_apply? = true
148
+ end
149
+
150
+ it "leaves the request value alone rather than overwriting it" do
151
+ expect(searched_with).to eq("starred_eq" => false)
152
+ end
153
+ end
154
+ end
155
+
156
+ describe "a resource without any default" do
157
+ let(:filters) { { title: {} } }
158
+
159
+ it "leaves the request alone" do
160
+ expect(searched_with).to eq({})
161
+ end
162
+ end
163
+
164
+ describe "a required filter's Proc default" do
165
+ it "is evaluated once per request, shared with the required grouping" do
166
+ calls = 0
167
+ controller = FakeController.new(
168
+ filters: { status_eq: { extensions: { default: -> { calls += 1; "published" }, required: true } } }
169
+ )
170
+
171
+ controller.send(:filtering_params)
172
+
173
+ expect(calls).to eq(1)
174
+ end
175
+ end
176
+
177
+ describe "a required filter answered with false" do
178
+ # Rack never delivers a boolean - `?q[starred_eq]=false` arrives as the String - but an
179
+ # override of #filtering_params can, and `false` is an answer everywhere else in the gem.
180
+ let(:filters) { { starred_eq: { extensions: { default: true, required: true } } } }
181
+ let(:params) { { q: { "starred_eq" => false } } }
182
+
183
+ it "is answered, so no default engages" do
184
+ expect(controller.send(:filter_defaults_engaged?)).to be(false)
185
+ end
186
+ end
187
+
188
+ describe "key derivation" do
189
+ # Deriving builds a real input, so the unit suite reaches it through a stub. A catch-all
190
+ # rescue turned every internal failure into "could not work out which search key it
191
+ # submits", which policy_search_keys then answered with a key of its own invention.
192
+ def deriving(error)
193
+ Class.new do
194
+ include ActiveAdminFilterExtensions::SearchKeys
195
+ define_method(:build_filter_input) { |_attribute, _options| raise error }
196
+ end.new
197
+ end
198
+
199
+ it "reports an input it cannot build" do
200
+ # No `ransack` on the resource - what a proxy page backed by an HTTP API answers.
201
+ controller = deriving(NoMethodError.new("undefined method 'ransack'"))
202
+
203
+ expect { controller.send(:filter_search_keys, :created_at, {}) }
204
+ .to raise_error(ArgumentError, /could not work out which search key it submits/)
205
+ end
206
+
207
+ it "lets a failure that is not a derivation failure through" do
208
+ controller = deriving(RuntimeError.new("boom"))
209
+
210
+ expect { controller.send(:filter_search_keys, :created_at, {}) }
211
+ .to raise_error(RuntimeError, "boom")
212
+ end
213
+ end
214
+
215
+ describe "a policy on a filter whose search key cannot be derived" do
216
+ # The derivation needs a resource Ransack can search; a proxy page backed by an HTTP API
217
+ # has none, and a policy must not turn every index of such a page into a 500. The declared
218
+ # filter name IS the submitted key on those pages, so the guard falls back to it.
219
+ let(:proxy_controller_class) do
220
+ Class.new(FakeController) do
221
+ def filter_search_keys(_attribute, _options)
222
+ raise ArgumentError, "could not work out which search key it submits"
223
+ end
224
+ end
225
+ end
226
+ let(:controller) { proxy_controller_class.new(params: params, filters: filters) }
227
+ let(:filters) { { time_start_gteq: { extensions: { sanitize: ->(v) { v.strip }, default: "keep" } } } }
228
+ let(:params) { { q: { "time_start_gteq" => " 2026-01-01 " } } }
229
+
230
+ it "falls back to the attribute name and still enforces" do
231
+ controller.send(:enforce_filter_value_policies)
232
+ expect(controller.params[:q]["time_start_gteq"]).to eq("2026-01-01")
233
+ end
234
+
235
+ context "when the filter declares a window policy" do
236
+ # The fallback gives one key, and a window policy demands two - so the very page the
237
+ # fallback keeps alive raised "needs a two-ended input" on every request instead.
238
+ let(:filters) { { time_start_gteq: { extensions: { max_range: 86_400 } } } }
239
+ let(:params) { { q: { "time_start_gteq" => "2026-01-01" } } }
240
+
241
+ it "logs and skips the window instead of raising" do
242
+ warnings = []
243
+ logger = Object.new
244
+ logger.define_singleton_method(:warn) { |message| warnings << message }
245
+ controller.define_singleton_method(:policy_logger) { logger }
246
+
247
+ expect { controller.send(:enforce_filter_value_policies) }.not_to raise_error
248
+ expect(warnings.first).to match(/could not be derived/)
249
+ end
250
+ end
251
+ end
252
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails_helper"
4
+
5
+ # Everything of the gem's lives under the one extensions: option. Both ways out of the container
6
+ # are loud: a gem key left flat would otherwise mean something else to Formtastic (required:),
7
+ # and a typo inside would silently guard nothing.
8
+ RSpec.describe "the extensions: container", type: :feature do
9
+ it "reads a string-keyed container" do
10
+ # The unknown-key check symbolizes before comparing, so "default" => ... passed it and then
11
+ # merged under a String key the gem never reads: a declared default that quietly did nothing.
12
+ Post.create!(title: "keep me", status: "published", position: 20)
13
+ Post.create!(title: "drop me", status: "draft", position: 1)
14
+
15
+ visit "/admin/string_keyed_extensions_posts"
16
+
17
+ expect(page).to have_content("keep me")
18
+ expect(page).to have_no_content("drop me")
19
+ end
20
+
21
+ it "raises for a gem option left outside extensions:" do
22
+ expect { visit "/admin/banana_flat_option_posts" }
23
+ .to raise_error(ArgumentError, /:default.*belong inside extensions:/m)
24
+ end
25
+
26
+ it "leaves a flat required: and min: to Formtastic" do
27
+ # `filter :position, as: :numeric, min: 0` is legal Active Admin. Annexed, the gem read
28
+ # min: as a window policy on a single-ended input and 500'd every index of the resource.
29
+ visit "/admin/host_option_posts"
30
+
31
+ expect(page).to have_css("input[name='q[position]'][min='0']")
32
+ end
33
+
34
+ it "raises for an unknown key inside extensions:" do
35
+ expect { visit "/admin/banana_policy_key_posts" }
36
+ .to raise_error(ArgumentError, /unknown extensions: :defult/m)
37
+ end
38
+ end