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,229 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails_helper"
|
|
4
|
+
require "rack/utils"
|
|
5
|
+
|
|
6
|
+
# The sample app registers Post once per filter input type - see spec/support/rails_template.rb.
|
|
7
|
+
# Every default below selects `keep me` and rejects `drop me`, so one pair of records exercises
|
|
8
|
+
# all of them. Capybara follows the one canonicalizing redirect, so every example asserts the
|
|
9
|
+
# final page.
|
|
10
|
+
RSpec.describe "Filter default values", type: :feature do
|
|
11
|
+
before do
|
|
12
|
+
AdminUser.create!(email: "admin@example.com", default_status: "published")
|
|
13
|
+
|
|
14
|
+
Post.create!(title: "keep me", body: "keep body", status: "published",
|
|
15
|
+
position: 20, published_date: Date.new(2026, 6, 1), starred: true)
|
|
16
|
+
Post.create!(title: "drop me", body: "drop body", status: "draft",
|
|
17
|
+
position: 1, published_date: Date.new(2020, 1, 1), starred: false)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
describe "every filter input type" do
|
|
21
|
+
{
|
|
22
|
+
"string, predicate in the Hash key" => "/admin/string_posts",
|
|
23
|
+
"string, predicate already in the filter name" => "/admin/scalar_posts",
|
|
24
|
+
"numeric" => "/admin/numeric_posts",
|
|
25
|
+
"date range" => "/admin/date_posts",
|
|
26
|
+
"select" => "/admin/select_posts",
|
|
27
|
+
"check boxes" => "/admin/check_boxes_posts",
|
|
28
|
+
"boolean" => "/admin/boolean_posts"
|
|
29
|
+
}.each do |input_type, path|
|
|
30
|
+
it "applies the declared default to a #{input_type} filter" do
|
|
31
|
+
visit path
|
|
32
|
+
|
|
33
|
+
expect(page).to have_content("keep me")
|
|
34
|
+
expect(page).to have_no_content("drop me")
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# `Proc#[]` is `call`, so a Proc left in `:input_html` answers Formtastic's `[:multiple]`
|
|
40
|
+
# check with a truthy Hash and a select derives `_in` instead of `_eq` - filtering nothing,
|
|
41
|
+
# silently. The Proc is resolved first, the way the filters form resolves it.
|
|
42
|
+
it "resolves a Proc :input_html before asking the input" do
|
|
43
|
+
visit "/admin/proc_input_html_posts"
|
|
44
|
+
|
|
45
|
+
expect(page).to have_content("keep me")
|
|
46
|
+
expect(page).to have_no_content("drop me")
|
|
47
|
+
expect(page).to have_select("q[status_eq]", selected: "published")
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
describe "a value that cannot be placed" do
|
|
51
|
+
it "says so instead of guessing" do
|
|
52
|
+
expect { visit "/admin/ambiguous_posts" }
|
|
53
|
+
.to raise_error(ArgumentError, /filter :published_date declares a `default:`.*single value cannot say which to fill/m)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
it "says so for an exclusive Range instead of widening it" do
|
|
57
|
+
# The inputs submit gteq and lteq, so an excluded end has no spelling: stored as lteq it
|
|
58
|
+
# searched the day the Range left out, and the redirect froze that bound into every
|
|
59
|
+
# copied URL. Every other Range ambiguity here raises; this one changed data quietly.
|
|
60
|
+
expect { visit "/admin/banana_exclusive_range_posts" }
|
|
61
|
+
.to raise_error(ArgumentError, /exclusive Range.*use an inclusive range/m)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Filtering the collection is only half of it: the admin has to be able to see what the page
|
|
66
|
+
# decided on their behalf, and change it. Every input type is checked, because each renders
|
|
67
|
+
# its value differently and a default that filters invisibly is the thing to avoid.
|
|
68
|
+
describe "the value the admin sees in the form" do
|
|
69
|
+
it "shows it on a string filter" do
|
|
70
|
+
visit "/admin/string_posts"
|
|
71
|
+
|
|
72
|
+
expect(page).to have_field("q[title_cont]", with: "keep")
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
it "shows it on a string filter whose name carries the predicate" do
|
|
76
|
+
visit "/admin/scalar_posts"
|
|
77
|
+
|
|
78
|
+
expect(page).to have_field("q[title_cont]", with: "keep")
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
it "shows it on a select filter" do
|
|
82
|
+
visit "/admin/select_posts"
|
|
83
|
+
|
|
84
|
+
expect(page).to have_select("q[status_eq]", selected: "published")
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
it "shows it on a check boxes filter" do
|
|
88
|
+
visit "/admin/check_boxes_posts"
|
|
89
|
+
|
|
90
|
+
expect(page).to have_checked_field("q[status_in][]", with: "published")
|
|
91
|
+
expect(page).to have_unchecked_field("q[status_in][]", with: "draft")
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
it "shows it on a boolean filter" do
|
|
95
|
+
visit "/admin/boolean_posts"
|
|
96
|
+
|
|
97
|
+
expect(page).to have_select("q[starred_eq]", selected: "Yes")
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
it "shows it on both ends of a date range filter" do
|
|
101
|
+
visit "/admin/date_posts"
|
|
102
|
+
|
|
103
|
+
expect(page).to have_field("q[published_date_gteq]", with: "2026-01-01")
|
|
104
|
+
expect(page).to have_field("q[published_date_lteq]", with: "")
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
it "shows it on a numeric filter, with the predicate its Hash named selected" do
|
|
108
|
+
visit "/admin/numeric_posts"
|
|
109
|
+
|
|
110
|
+
expect(page).to have_field("q[position_gt]", with: "10")
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
it "shows the one read off the signed in admin" do
|
|
114
|
+
visit "/admin/admin_preference_posts"
|
|
115
|
+
|
|
116
|
+
expect(page).to have_select("q[status_eq]", selected: "published")
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
it "shows everything once the field is blanked and the form submitted" do
|
|
120
|
+
visit "/admin/string_posts"
|
|
121
|
+
fill_in "q[title_cont]", with: ""
|
|
122
|
+
click_button "Filter"
|
|
123
|
+
|
|
124
|
+
expect(page).to have_content("keep me")
|
|
125
|
+
expect(page).to have_content("drop me")
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
it "leaves a resource that declares no default alone" do
|
|
129
|
+
visit "/admin/plain_posts"
|
|
130
|
+
|
|
131
|
+
expect(page).to have_content("keep me")
|
|
132
|
+
expect(page).to have_content("drop me")
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
describe ":if and :unless" do
|
|
137
|
+
it "neither renders a denied filter nor lets it impose its default" do
|
|
138
|
+
visit "/admin/conditional_posts"
|
|
139
|
+
|
|
140
|
+
# `title` is denied by `:if`, `body` by `:unless`: their inputs are gone, and their
|
|
141
|
+
# defaults are not in force either - `drop me` is excluded by the `status` filter alone.
|
|
142
|
+
expect(page).to have_no_field("q[title_cont]")
|
|
143
|
+
expect(page).to have_no_field("q[body_cont]")
|
|
144
|
+
expect(page).to have_field("q[status_eq]")
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
it "renders the index when every filter of the resource is denied" do
|
|
148
|
+
visit "/admin/all_hidden_posts"
|
|
149
|
+
|
|
150
|
+
expect(page).to have_content("keep me")
|
|
151
|
+
expect(page).to have_content("drop me")
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
it "applies the default of the filter its condition allows" do
|
|
155
|
+
visit "/admin/conditional_posts"
|
|
156
|
+
|
|
157
|
+
expect(page).to have_content("keep me")
|
|
158
|
+
expect(page).to have_no_content("drop me")
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
describe "a default read off the signed in admin" do
|
|
163
|
+
it "filters on what the admin saved" do
|
|
164
|
+
visit "/admin/admin_preference_posts"
|
|
165
|
+
|
|
166
|
+
expect(page).to have_content("keep me")
|
|
167
|
+
expect(page).to have_no_content("drop me")
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
it "applies no filter when the admin saved nothing" do
|
|
171
|
+
AdminUser.update_all(default_status: nil)
|
|
172
|
+
|
|
173
|
+
visit "/admin/admin_preference_posts"
|
|
174
|
+
|
|
175
|
+
expect(page).to have_content("keep me")
|
|
176
|
+
expect(page).to have_content("drop me")
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
describe "default_filters_notice" do
|
|
181
|
+
it "tells the admin why the list is cut down" do
|
|
182
|
+
visit "/admin/colliding_notice_posts"
|
|
183
|
+
|
|
184
|
+
expect(page).to have_content("Showing published posts by default")
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
it "stays quiet once the admin has filtered for themselves" do
|
|
188
|
+
visit "/admin/colliding_notice_posts"
|
|
189
|
+
select "draft", from: "q[status_eq]"
|
|
190
|
+
click_button "Filter"
|
|
191
|
+
|
|
192
|
+
expect(page).to have_no_content("Showing published posts by default")
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
describe "requests that carry no q of their own" do
|
|
197
|
+
it "keeps the default while paging" do
|
|
198
|
+
visit "/admin/string_posts?page=1"
|
|
199
|
+
|
|
200
|
+
expect(page).to have_no_content("drop me")
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# The download link is built from params, which carry no `q` while the index is showing its
|
|
204
|
+
# declared defaults, so the CSV request has to derive them again to match the screen.
|
|
205
|
+
it "exports the filtered collection to CSV, not the whole table" do
|
|
206
|
+
visit "/admin/string_posts"
|
|
207
|
+
click_link "CSV"
|
|
208
|
+
|
|
209
|
+
expect(page.body).to include("keep me")
|
|
210
|
+
expect(page.body).to_not include("drop me")
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# Clear Filters strips `q[`, `page`, `utf8` and `commit` from the query string, which is the
|
|
215
|
+
# state a first visit is in, so it returns the page to its declared defaults rather than to an
|
|
216
|
+
# empty filter set.
|
|
217
|
+
it "returns to the defaults after Clear Filters", :js do
|
|
218
|
+
visit "/admin/string_posts"
|
|
219
|
+
fill_in "q[title_cont]", with: ""
|
|
220
|
+
click_button "Filter"
|
|
221
|
+
expect(page).to have_content("drop me")
|
|
222
|
+
|
|
223
|
+
click_link "Clear Filters"
|
|
224
|
+
|
|
225
|
+
expect(page).to have_content("keep me")
|
|
226
|
+
expect(page).to have_no_content("drop me")
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
end
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails_helper"
|
|
4
|
+
require "rack/utils"
|
|
5
|
+
|
|
6
|
+
# The built-in numeric pair: `sanitize: :digits` strips a value to its digits, `validate:
|
|
7
|
+
# :integer` refuses what does not fit a PG int4 - the guard every inventoried app hand-rolled
|
|
8
|
+
# around its id/position filters.
|
|
9
|
+
RSpec.describe "built-in numeric policies", type: :feature do
|
|
10
|
+
before do
|
|
11
|
+
Post.create!(title: "keep me", body: "keep body", status: "published",
|
|
12
|
+
position: 20, published_date: Date.new(2026, 6, 1), starred: true)
|
|
13
|
+
Post.create!(title: "drop me", body: "drop body", status: "draft",
|
|
14
|
+
position: 1, published_date: Date.new(2020, 1, 1), starred: false)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def query_of(url)
|
|
18
|
+
Rack::Utils.parse_nested_query(URI.parse(url).query.to_s)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
describe "validate: :integer" do
|
|
22
|
+
it "refuses a value that does not fit a PG int4" do
|
|
23
|
+
visit "/admin/integer_posts?q%5Bposition_eq%5D=2147483648"
|
|
24
|
+
|
|
25
|
+
expect(page).to have_content("Position eq filter has an invalid value.")
|
|
26
|
+
expect(URI.parse(page.current_url).query).to be_nil
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
it "lets the int4 maximum itself through" do
|
|
30
|
+
visit "/admin/integer_posts?q%5Bposition_eq%5D=2147483647"
|
|
31
|
+
|
|
32
|
+
expect(query_of(page.current_url)["q"]).to eq("position_eq" => "2147483647")
|
|
33
|
+
expect(page).to have_no_content("invalid value")
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
describe "a default through its own sanitizer" do
|
|
38
|
+
it "stores and says the sanitized value on every format" do
|
|
39
|
+
# Not only the HTML path (which would self-heal with an extra hop): the CSV runs
|
|
40
|
+
# filtering_params directly and must search what the sanitizer produced.
|
|
41
|
+
visit "/admin/digit_default_posts.csv"
|
|
42
|
+
|
|
43
|
+
expect(page.body).to include("keep me")
|
|
44
|
+
expect(page.body).not_to include("drop me")
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
it "sanitizes a non-String default the same way every format will see it" do
|
|
48
|
+
visit "/admin/signed_default_posts.csv"
|
|
49
|
+
|
|
50
|
+
expect(page.body).to include("keep me")
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
it "lands the sanitized default in the URL in one hop" do
|
|
54
|
+
visit "/admin/digit_default_posts"
|
|
55
|
+
|
|
56
|
+
expect(query_of(page.current_url)["q"]).to eq("position_eq" => "20")
|
|
57
|
+
expect(page).to have_content("keep me")
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
describe "sibling predicates" do
|
|
62
|
+
# The filter's own predicate dropdown submits _gt/_lt/_not_eq and friends; a policy
|
|
63
|
+
# guarding only the head key leaves the same column one dropdown click from unpoliced.
|
|
64
|
+
it "polices a sibling predicate the dropdown submits" do
|
|
65
|
+
visit "/admin/integer_posts?q%5Bposition_gt%5D=2147483648"
|
|
66
|
+
|
|
67
|
+
expect(page).to have_content("has an invalid value")
|
|
68
|
+
expect(URI.parse(page.current_url).query).to be_nil
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
it "refills the owning filter's default after a sibling reset" do
|
|
72
|
+
# The reset deletes the sibling key; the refill promise belongs to the FILTER - its
|
|
73
|
+
# declared default comes back even though the submitted key never carried one.
|
|
74
|
+
visit "/admin/guarded_posts?q%5Btitle_eq%5D=keep123&commit=Filter"
|
|
75
|
+
|
|
76
|
+
expect(query_of(page.current_url)["q"]).to eq("title_cont" => "keep")
|
|
77
|
+
expect(page).to have_content("keep me")
|
|
78
|
+
expect(page).to have_no_content("drop me")
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
it "sanitizes a sibling predicate too" do
|
|
82
|
+
visit "/admin/integer_posts?q%5Bposition_lt%5D=2%200a"
|
|
83
|
+
|
|
84
|
+
expect(query_of(page.current_url)["q"]).to eq("position_lt" => "20")
|
|
85
|
+
expect(page).to have_content("drop me")
|
|
86
|
+
expect(page).to have_no_content("keep me")
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
describe "sanitize: :digits" do
|
|
91
|
+
it "strips the value to its digits and searches that" do
|
|
92
|
+
visit "/admin/integer_posts?q%5Bposition_eq%5D=%202-0a"
|
|
93
|
+
|
|
94
|
+
expect(query_of(page.current_url)["q"]).to eq("position_eq" => "20")
|
|
95
|
+
expect(page).to have_content("keep me")
|
|
96
|
+
expect(page).to have_no_content("drop me")
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
it "raises for an unknown built-in sanitizer" do
|
|
100
|
+
expect { visit "/admin/banana_sanitize_builtin_posts?q%5Btitle_cont%5D=x" }
|
|
101
|
+
.to raise_error(ArgumentError, /unknown built-in sanitizer/)
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails_helper"
|
|
4
|
+
require "rack/utils"
|
|
5
|
+
|
|
6
|
+
# `on_invalid:` names what a violated policy does. :reset (the default) is covered elsewhere;
|
|
7
|
+
# here the other two: :reject bounces back, :empty shows nothing but says why.
|
|
8
|
+
RSpec.describe "on_invalid policies", type: :feature do
|
|
9
|
+
before do
|
|
10
|
+
Post.create!(title: "keep me", body: "keep body", status: "published",
|
|
11
|
+
position: 20, published_date: Date.new(2026, 6, 1), starred: true)
|
|
12
|
+
Post.create!(title: "drop me", body: "drop body", status: "draft",
|
|
13
|
+
position: 1, published_date: Date.new(2020, 1, 1), starred: false)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def query_of(url)
|
|
17
|
+
Rack::Utils.parse_nested_query(URI.parse(url).query.to_s)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
describe ":reject" do
|
|
21
|
+
it "flashes and bounces to the bare index" do
|
|
22
|
+
visit "/admin/reject_posts?q%5Bposition_eq%5D=abc"
|
|
23
|
+
|
|
24
|
+
expect(page).to have_content("Position filter has an invalid value.")
|
|
25
|
+
expect(URI.parse(page.current_url).query).to be_nil
|
|
26
|
+
expect(page).to have_content("keep me")
|
|
27
|
+
expect(page).to have_content("drop me")
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
it "bounces once even when several filters are violated" do
|
|
31
|
+
# redirect_back does not break Ruby loops; without a performed? guard the second
|
|
32
|
+
# violation double-renders into a 500 instead of the promised bounce.
|
|
33
|
+
visit "/admin/reject_posts?q%5Bposition_eq%5D=abc&q%5Btitle_cont%5D=keep123"
|
|
34
|
+
|
|
35
|
+
expect(URI.parse(page.current_url).query).to be_nil
|
|
36
|
+
expect(page).to have_content("filter has an invalid value.").or have_content("must not contain digits")
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
it "bounces to the bare index when back is the offending URL itself" do
|
|
40
|
+
# Browsers keep the original Referer across a 302: redirect_back would re-reject the
|
|
41
|
+
# same request forever, dying at the browser's redirect cap instead of showing the
|
|
42
|
+
# error.
|
|
43
|
+
offending = "/admin/reject_posts?q%5Bposition_eq%5D=abc"
|
|
44
|
+
page.driver.header("Referer", "http://www.example.com#{offending}")
|
|
45
|
+
visit offending
|
|
46
|
+
|
|
47
|
+
expect(URI.parse(page.current_url).query).to be_nil
|
|
48
|
+
expect(page).to have_content("Position filter has an invalid value.")
|
|
49
|
+
ensure
|
|
50
|
+
page.driver.header("Referer", nil)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
it "degrades to :reset off the HTML path" do
|
|
54
|
+
# A CSV index is answered in one request everywhere else in the gem, a flash it cannot
|
|
55
|
+
# render would be lost, and url_for drops the format - so the export is bounded the
|
|
56
|
+
# :reset way instead of being bounced into an HTML page.
|
|
57
|
+
visit "/admin/reject_posts.csv?q%5Bposition_eq%5D=abc"
|
|
58
|
+
|
|
59
|
+
expect(page.response_headers["Content-Type"]).to include("text/csv")
|
|
60
|
+
expect(page.body).to include("keep me")
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
it "bounces once when value and window policies are violated together" do
|
|
64
|
+
visit "/admin/reject_window_posts?q%5Bpublished_date_gteq%5D=2020-01-01X&q%5Bpublished_date_lteq%5D=2026-01-01X"
|
|
65
|
+
|
|
66
|
+
expect(URI.parse(page.current_url).query).to be_nil
|
|
67
|
+
expect(page).to have_content("No X dates.")
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
describe ":empty" do
|
|
72
|
+
it "shows nothing, says why, and keeps the admin's input on screen" do
|
|
73
|
+
visit "/admin/empty_posts?q%5Btitle_cont%5D=keep123"
|
|
74
|
+
|
|
75
|
+
expect(page).to have_content("Title filter must not contain digits.")
|
|
76
|
+
expect(page).to have_no_content("keep me")
|
|
77
|
+
expect(page).to have_no_content("drop me")
|
|
78
|
+
expect(page).to have_field("q[title_cont]", with: "keep123")
|
|
79
|
+
expect(URI.parse(page.current_url).query).to eq("q%5Btitle_cont%5D=keep123")
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
it "survives the canonicalizing redirect when the sanitizer empties the value" do
|
|
83
|
+
# digits("abc") is "" - the blank would otherwise redirect away, and the next request,
|
|
84
|
+
# flagless, renders the whole table.
|
|
85
|
+
visit "/admin/empty_digits_posts?q%5Bposition_eq%5D=abc"
|
|
86
|
+
|
|
87
|
+
expect(URI.parse(page.current_url).query).to eq("q%5Bposition_eq%5D=abc")
|
|
88
|
+
expect(page).to have_content("filter has an invalid value")
|
|
89
|
+
# The admin's own input is preserved (not the sanitizer's ""): the URL carries it and
|
|
90
|
+
# the form re-renders it - as Ransack casts it for this integer attribute, so "0".
|
|
91
|
+
# The literal-text case is EmptyPost's string-attribute example above.
|
|
92
|
+
expect(page).to have_field("q[position_eq]", with: "0")
|
|
93
|
+
expect(page).to have_no_content("keep me")
|
|
94
|
+
expect(page).to have_no_content("drop me")
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
it "validates array elements emptied by the sanitizer and keeps a checkbox input" do
|
|
98
|
+
visit "/admin/empty_digits_array_posts"
|
|
99
|
+
check "Not a number"
|
|
100
|
+
click_button "Filter"
|
|
101
|
+
|
|
102
|
+
expect(page).to have_content("filter has an invalid value")
|
|
103
|
+
expect(query_of(page.current_url)["q"]).to eq("status_in" => ["abc"])
|
|
104
|
+
expect(page).to have_checked_field("q[status_in][]", with: "abc")
|
|
105
|
+
expect(page).to have_no_content("keep me")
|
|
106
|
+
expect(page).to have_no_content("drop me")
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
it "leaves a valid search alone" do
|
|
110
|
+
visit "/admin/empty_posts?q%5Btitle_cont%5D=drop"
|
|
111
|
+
|
|
112
|
+
expect(page).to have_content("drop me")
|
|
113
|
+
expect(page).to have_no_content("keep me")
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
describe "a default against its own value policy" do
|
|
118
|
+
it "raises instead of flashing itself forever" do
|
|
119
|
+
# Accepted, the default refills on every bare visit, fails its own validator, resets,
|
|
120
|
+
# and refills - a permanent self-inflicted error banner.
|
|
121
|
+
expect { visit "/admin/banana_validated_default_posts" }
|
|
122
|
+
.to raise_error(ArgumentError, /default that violates its own validate/)
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
describe "a default against its own value policy" do
|
|
127
|
+
it "raises for a non-String default its validator refuses as submitted" do
|
|
128
|
+
# The URL will say the default as a String - that is the value to judge.
|
|
129
|
+
expect { visit "/admin/banana_integer_default_posts" }
|
|
130
|
+
.to raise_error(ArgumentError, /default that violates its own validate/)
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
describe "values the URL cannot carry" do
|
|
135
|
+
it "raises for a sanitizer with no fixpoint instead of redirect-looping" do
|
|
136
|
+
expect { visit "/admin/banana_sanitize_posts?q%5Btitle_cont%5D=acme" }
|
|
137
|
+
.to raise_error(ArgumentError, /sanitize.*idempotent/m)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
it "raises for a default its sanitizer keeps rewriting" do
|
|
141
|
+
# The default faces the same fixpoint check submitted values do - otherwise the CSV
|
|
142
|
+
# searches the once-sanitized value while the HTML converges on another.
|
|
143
|
+
expect { visit "/admin/banana_shrinking_default_posts" }
|
|
144
|
+
.to raise_error(ArgumentError, /sanitize.*idempotent/m)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
it "raises for a sanitizer that mutates the value in place" do
|
|
148
|
+
# `value << "%"` returns the SAME object - compared against itself, the no-fixpoint
|
|
149
|
+
# guard would wave the loop through.
|
|
150
|
+
expect { visit "/admin/banana_mutating_sanitize_posts?q%5Btitle_cont%5D=acme" }
|
|
151
|
+
.to raise_error(ArgumentError, /sanitize.*idempotent/m)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
it "does not chase a default that serializes to nothing" do
|
|
155
|
+
# {}.to_query drops the pair entirely - same loop shape as the empty Array.
|
|
156
|
+
visit "/admin/empty_hash_default_posts"
|
|
157
|
+
|
|
158
|
+
expect(URI.parse(page.current_url).query).to be_nil
|
|
159
|
+
expect(page).to have_content("keep me")
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
describe "sanitize: rewrites the URL to what actually ran" do
|
|
164
|
+
it "redirects once to the sanitized value, without greeting it as a default" do
|
|
165
|
+
visit "/admin/guarded_posts?q%5Btitle_cont%5D=ke-ep"
|
|
166
|
+
|
|
167
|
+
expect(query_of(page.current_url)["q"]).to eq("title_cont" => "keep")
|
|
168
|
+
expect(page).to have_content("keep me")
|
|
169
|
+
# The search ran the admin's own (sanitized) value - no default engaged, so the
|
|
170
|
+
# travelling notice must stay home.
|
|
171
|
+
expect(page).to have_no_content("Showing kept posts by default")
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|