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,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,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
|