admin_suite 0.3.1 → 0.5.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 (70) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +210 -1
  3. data/app/assets/vendor/chart.umd.min.js +14 -0
  4. data/app/assets/vendor/easymde.min.css +7 -0
  5. data/app/assets/vendor/easymde.min.js +7 -0
  6. data/app/controllers/admin_suite/application_controller.rb +26 -47
  7. data/app/controllers/admin_suite/portals_controller.rb +2 -2
  8. data/app/controllers/admin_suite/resources_controller.rb +138 -6
  9. data/app/helpers/admin_suite/base_helper.rb +232 -391
  10. data/app/javascript/admin_suite_application.js +3 -0
  11. data/app/javascript/controllers/admin_suite/chart_controller.js +173 -0
  12. data/app/javascript/controllers/admin_suite/markdown_editor_controller.js +21 -2
  13. data/app/views/admin_suite/panels/_chart.html.erb +158 -18
  14. data/app/views/admin_suite/panels/_stat.html.erb +10 -0
  15. data/app/views/admin_suite/resources/index.html.erb +31 -82
  16. data/app/views/admin_suite/shared/_pagination.html.erb +74 -0
  17. data/app/views/admin_suite/shared/_sidebar.html.erb +15 -9
  18. data/app/views/layouts/admin_suite/application.html.erb +11 -3
  19. data/config/routes.rb +3 -0
  20. data/lib/admin/base/action_executor.rb +19 -51
  21. data/lib/admin/base/filter_builder.rb +48 -5
  22. data/lib/admin/base/resource.rb +92 -17
  23. data/lib/admin_suite/configuration.rb +32 -3
  24. data/lib/admin_suite/definition_loader.rb +194 -0
  25. data/lib/admin_suite/deprecation.rb +48 -0
  26. data/lib/admin_suite/engine.rb +55 -76
  27. data/lib/admin_suite/host_autoload_policy.rb +132 -0
  28. data/lib/admin_suite/legacy_custom_renderer_procs.rb +29 -0
  29. data/lib/admin_suite/portal_definition.rb +11 -0
  30. data/lib/admin_suite/renderer.rb +133 -0
  31. data/lib/admin_suite/renderer_registry.rb +81 -0
  32. data/lib/admin_suite/renderers/code_renderer.rb +15 -0
  33. data/lib/admin_suite/renderers/json_renderer.rb +15 -0
  34. data/lib/admin_suite/renderers/key_value_renderer.rb +39 -0
  35. data/lib/admin_suite/renderers/legacy_gleania.rb +232 -0
  36. data/lib/admin_suite/renderers/table_from_renderer.rb +22 -0
  37. data/lib/admin_suite/section_definition.rb +42 -0
  38. data/lib/admin_suite/ui/dashboard_definition.rb +6 -0
  39. data/lib/admin_suite/ui/field_renderer_registry.rb +31 -4
  40. data/lib/admin_suite/ui/form_field_renderer.rb +1 -7
  41. data/lib/admin_suite/ui/show_formatter_registry.rb +9 -0
  42. data/lib/admin_suite/ui/show_value_formatter.rb +7 -3
  43. data/lib/admin_suite/version.rb +1 -1
  44. data/lib/admin_suite.rb +48 -0
  45. data/lib/generators/admin_suite/install/templates/admin_suite.rb +0 -4
  46. data/test/controllers/resources_controller_test.rb +76 -1
  47. data/test/integration/association_linking_test.rb +292 -0
  48. data/test/integration/chart_panel_test.rb +491 -0
  49. data/test/integration/dashboard_test.rb +9 -2
  50. data/test/integration/index_table_test.rb +289 -0
  51. data/test/integration/layout_assets_test.rb +112 -0
  52. data/test/integration/navigation_sections_test.rb +62 -0
  53. data/test/integration/pagination_and_stats_test.rb +152 -0
  54. data/test/integration/searchable_select_search_test.rb +368 -0
  55. data/test/integration/show_hide_blank_test.rb +195 -0
  56. data/test/integration/toggle_test.rb +106 -0
  57. data/test/lib/action_executor_redirect_test.rb +41 -0
  58. data/test/lib/builtin_renderers_test.rb +202 -0
  59. data/test/lib/definition_loader_test.rb +276 -0
  60. data/test/lib/engine_defaults_test.rb +39 -0
  61. data/test/lib/form_field_renderer_test.rb +136 -0
  62. data/test/lib/format_table_cell_test.rb +49 -0
  63. data/test/lib/index_includes_test.rb +223 -0
  64. data/test/lib/legacy_renderer_deprecation_test.rb +42 -0
  65. data/test/lib/renderer_test.rb +237 -0
  66. data/test/lib/resource_exportable_deprecation_test.rb +47 -0
  67. data/test/lib/show_value_formatter_test.rb +88 -0
  68. data/test/lib/zeitwerk_integration_test.rb +28 -64
  69. data/test/test_helper.rb +121 -5
  70. metadata +62 -5
@@ -0,0 +1,368 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+
5
+ # Fixtures for the gem-provided searchable_select search endpoint
6
+ # (ResourcesController#search). Self-contained per the pattern established in
7
+ # authorization_test.rb/pagination_and_stats_test.rb: `rake test
8
+ # TEST=test/integration/searchable_select_search_test.rb` must work with no
9
+ # other test file's fixtures loaded.
10
+ module SearchableSelectFixtures
11
+ class Company
12
+ extend ActiveModel::Naming
13
+
14
+ attr_reader :id, :name, :secret
15
+
16
+ def initialize(id:, name:, secret: nil)
17
+ @id = id
18
+ @name = name
19
+ @secret = secret
20
+ end
21
+
22
+ def self.records
23
+ @records ||= (1..30).map { |n| new(id: n, name: "Widget #{n}") } +
24
+ [ new(id: 1000, name: "Acme Corp", secret: "unicorn-marker") ]
25
+ end
26
+
27
+ def self.all = ReadOnlyResourceFixtures::Relation.new(records)
28
+ def self.column_names = %w[id name secret]
29
+ def self.primary_key = "id"
30
+ def self.columns_hash = { "id" => Struct.new(:type).new(:integer) }
31
+
32
+ def self.find(id)
33
+ records.find { |r| r.id.to_s == id.to_s } || raise(ActiveRecord::RecordNotFound)
34
+ end
35
+
36
+ # Fakes AR's `where(sql, binds)` well enough to exercise the real
37
+ # production predicate (`Admin::Base::FilterBuilder.search_predicate`)
38
+ # end to end in this database-free dummy app: parses the field name(s)
39
+ # out of the "<field> ILIKE :search" text the predicate builds -- which
40
+ # only ever contains names from the resource's declared `searchable`
41
+ # whitelist -- and substring-matches only *those* fields. A term that
42
+ # only appears in a non-searchable field (`secret`, below) can therefore
43
+ # never match, mirroring real ILIKE restricted to real whitelisted
44
+ # columns.
45
+ def self.where(conditions, binds = {})
46
+ term = binds[:search].to_s.delete_prefix("%").delete_suffix("%").downcase
47
+ fields = conditions.scan(/(\w+) ILIKE :search/).flatten
48
+ matches = records.select { |r| fields.any? { |f| r.public_send(f).to_s.downcase.include?(term) } }
49
+ ReadOnlyResourceFixtures::Relation.new(matches)
50
+ end
51
+
52
+ def to_param = id.to_s
53
+ def attributes = { "id" => id, "name" => name, "secret" => secret }
54
+ end
55
+
56
+ # Declares an index (so it's a real, otherwise-normal resource) but never
57
+ # calls `searchable`, leaving `searchable_fields` empty -- one of the
58
+ # hostile-input shapes the task brief calls out explicitly. `where` raises
59
+ # so any test that reaches it proves the guard in
60
+ # `ResourcesController#search_results` failed to short-circuit before
61
+ # querying.
62
+ class NoSearchWidget
63
+ extend ActiveModel::Naming
64
+
65
+ attr_reader :id, :name
66
+
67
+ def initialize(id:, name:)
68
+ @id = id
69
+ @name = name
70
+ end
71
+
72
+ def self.all = ReadOnlyResourceFixtures::Relation.new([ new(id: 1, name: "Solo widget") ])
73
+ def self.column_names = %w[id name]
74
+ def self.primary_key = "id"
75
+ def self.columns_hash = { "id" => Struct.new(:type).new(:integer) }
76
+ def self.find(_id) = new(id: 1, name: "Solo widget")
77
+
78
+ def self.where(*)
79
+ raise "must not query a resource with no searchable fields declared"
80
+ end
81
+
82
+ def to_param = id.to_s
83
+ def attributes = { "id" => id, "name" => name }
84
+ end
85
+
86
+ # No `index` block at all -- `index_config` itself is nil, a stricter
87
+ # version of the "no searchable fields" case above.
88
+ class NoIndexWidget
89
+ extend ActiveModel::Naming
90
+
91
+ attr_reader :id, :name
92
+
93
+ def initialize(id:, name:)
94
+ @id = id
95
+ @name = name
96
+ end
97
+
98
+ def self.all = ReadOnlyResourceFixtures::Relation.new([ new(id: 1, name: "Bare widget") ])
99
+ def self.column_names = %w[id name]
100
+ def self.primary_key = "id"
101
+ def self.columns_hash = { "id" => Struct.new(:type).new(:integer) }
102
+ def self.find(_id) = new(id: 1, name: "Bare widget")
103
+
104
+ def self.where(*)
105
+ raise "must not query a resource with no index config at all"
106
+ end
107
+
108
+ def to_param = id.to_s
109
+ def attributes = { "id" => id, "name" => name }
110
+ end
111
+
112
+ # Exercises `render_searchable_select`'s URL resolution: one field left to
113
+ # resolve its search URL automatically via `resource:`, one overridden
114
+ # with an explicit String `collection:` (must still win -- unchanged host
115
+ # behavior).
116
+ class Deal
117
+ extend ActiveModel::Naming
118
+
119
+ attr_accessor :id, :company_id, :vendor_id
120
+
121
+ def initialize(id: nil, company_id: nil, vendor_id: nil)
122
+ @id = id
123
+ @company_id = company_id
124
+ @vendor_id = vendor_id
125
+ end
126
+
127
+ def self.column_names = %w[id company_id vendor_id]
128
+ def persisted? = !id.nil?
129
+ def new_record? = !persisted?
130
+
131
+ # render_form_field unconditionally calls `resource.errors[field.name]`
132
+ # for every field; no test here exercises a validation error, so a Hash
133
+ # defaulting to `[]` is sufficient (same rationale as
134
+ # layout_assets_test.rb's MarkdownWidget).
135
+ def errors
136
+ Hash.new([])
137
+ end
138
+ end
139
+ end
140
+
141
+ module Admin
142
+ module Resources
143
+ class SearchableSelectCompanyResource < Admin::Base::Resource
144
+ model SearchableSelectFixtures::Company
145
+ portal :ops
146
+ section :observability
147
+
148
+ index do
149
+ searchable :name
150
+ columns { column :name }
151
+ end
152
+ end
153
+
154
+ class SearchableSelectNoSearchWidgetResource < Admin::Base::Resource
155
+ model SearchableSelectFixtures::NoSearchWidget
156
+ portal :ops
157
+ section :observability
158
+
159
+ index do
160
+ columns { column :name }
161
+ end
162
+ end
163
+
164
+ class SearchableSelectNoIndexWidgetResource < Admin::Base::Resource
165
+ model SearchableSelectFixtures::NoIndexWidget
166
+ portal :ops
167
+ section :observability
168
+ end
169
+
170
+ class SearchableSelectDealResource < Admin::Base::Resource
171
+ model SearchableSelectFixtures::Deal
172
+ portal :ops
173
+ section :observability
174
+
175
+ form do
176
+ field :company_id, type: :searchable_select, resource: :searchable_select_companies
177
+ field :vendor_id, type: :searchable_select, collection: "/custom/vendor/search"
178
+ end
179
+ end
180
+ end
181
+ end
182
+
183
+ module AdminSuite
184
+ class SearchableSelectSearchTest < ActionDispatch::IntegrationTest
185
+ COMPANIES_SEARCH = "/internal/admin_suite/ops/searchable_select_companies/search"
186
+ NO_SEARCH = "/internal/admin_suite/ops/searchable_select_no_search_widgets/search"
187
+ NO_INDEX = "/internal/admin_suite/ops/searchable_select_no_index_widgets/search"
188
+
189
+ def with_authorize(hook)
190
+ saved = AdminSuite.config.authorize
191
+ AdminSuite.config.authorize = hook
192
+ yield
193
+ ensure
194
+ AdminSuite.config.authorize = saved
195
+ end
196
+
197
+ test "returns matching records as a bare JSON array with id and name" do
198
+ get COMPANIES_SEARCH, params: { q: "Acme" }
199
+ assert_response :success
200
+
201
+ body = JSON.parse(response.body)
202
+ assert_instance_of Array, body
203
+ assert_equal [ { "id" => 1000, "name" => "Acme Corp" } ], body
204
+ end
205
+
206
+ test "the response is a bare array, never {results: [...]}" do
207
+ get COMPANIES_SEARCH, params: { q: "Widget" }
208
+ assert_response :success
209
+ assert_kind_of Array, JSON.parse(response.body)
210
+ end
211
+
212
+ test "caps results at 25 even when more records match" do
213
+ get COMPANIES_SEARCH, params: { q: "Widget" }
214
+ assert_response :success
215
+ assert_equal 25, JSON.parse(response.body).size
216
+ end
217
+
218
+ test "empty q returns an empty array, not the whole table" do
219
+ get COMPANIES_SEARCH, params: { q: "" }
220
+ assert_response :success
221
+ assert_equal [], JSON.parse(response.body)
222
+ end
223
+
224
+ test "a missing q param returns an empty array" do
225
+ get COMPANIES_SEARCH
226
+ assert_response :success
227
+ assert_equal [], JSON.parse(response.body)
228
+ end
229
+
230
+ test "only searches the resource's declared searchable fields, never an arbitrary column" do
231
+ get COMPANIES_SEARCH, params: { q: "unicorn-marker" }
232
+ assert_response :success
233
+ assert_equal [], JSON.parse(response.body),
234
+ "a term that only exists in a non-searchable column must never match"
235
+ end
236
+
237
+ test "a resource with no searchable fields declared returns empty results without querying the model" do
238
+ get NO_SEARCH, params: { q: "widget" }
239
+ assert_response :success
240
+ assert_equal [], JSON.parse(response.body)
241
+ end
242
+
243
+ test "a resource with no index config at all returns empty results without querying the model" do
244
+ get NO_INDEX, params: { q: "widget" }
245
+ assert_response :success
246
+ assert_equal [], JSON.parse(response.body)
247
+ end
248
+
249
+ test "unknown resource name 404s" do
250
+ get "/internal/admin_suite/ops/totally_unregistered_things/search", params: { q: "x" }
251
+ assert_response :not_found
252
+ end
253
+
254
+ test "a resource name with path traversal characters 404s rather than erroring" do
255
+ get "/internal/admin_suite/ops/#{ERB::Util.url_encode('../../etc/passwd')}/search", params: { q: "x" }
256
+ assert_response :not_found
257
+ end
258
+
259
+ test "denies with 403 when config.authorize denies" do
260
+ with_authorize(->(**) { false }) do
261
+ get COMPANIES_SEARCH, params: { q: "Acme" }
262
+ end
263
+ assert_response :forbidden
264
+ end
265
+
266
+ test "authorize hook receives action: :read, the resource, a nil record, and the controller" do
267
+ captured = nil
268
+ hook = lambda do |actor:, action:, resource:, record:, controller:|
269
+ captured = { action: action, resource: resource, record: record, controller: controller.class }
270
+ true
271
+ end
272
+
273
+ with_authorize(hook) { get COMPANIES_SEARCH, params: { q: "Acme" } }
274
+
275
+ assert_equal :read, captured[:action]
276
+ assert_equal Admin::Resources::SearchableSelectCompanyResource, captured[:resource]
277
+ assert_nil captured[:record]
278
+ assert_equal AdminSuite::ResourcesController, captured[:controller]
279
+ end
280
+
281
+ test "a stray ?id= is never loaded and never reaches authorize's record:" do
282
+ captured_record = :not_set
283
+ hook = lambda do |record:, **|
284
+ captured_record = record
285
+ true
286
+ end
287
+
288
+ # Company id 1000 ("Acme Corp") genuinely exists in this fixture --
289
+ # this must not load it, unlike show/edit/update/destroy, where an
290
+ # `:id` is expected and meaningful.
291
+ with_authorize(hook) { get COMPANIES_SEARCH, params: { q: "Acme", id: "1000" } }
292
+
293
+ assert_response :success
294
+ assert_nil captured_record,
295
+ "search must never load a record from a stray ?id= -- doing so hands an " \
296
+ "attacker-chosen record to config.authorize and creates a 404-vs-403 " \
297
+ "existence oracle over find_friendly_resource!'s slug/uuid/token lookups"
298
+ end
299
+
300
+ test "a stray ?id= for a nonexistent record does not 404 before authorize runs" do
301
+ # Before excluding `search` from `set_resource`, an unmatched id here
302
+ # raised ActiveRecord::RecordNotFound (404) *before* authorize_admin_suite!
303
+ # ever ran -- letting a denied actor distinguish "no such id" from
304
+ # "denied" by id, on a resource they have no read access to.
305
+ hook_called = false
306
+ with_authorize(->(**) { hook_called = true; false }) do
307
+ get COMPANIES_SEARCH, params: { q: "Acme", id: "999999" }
308
+ end
309
+
310
+ assert_response :forbidden
311
+ assert hook_called, "authorize must run even with a nonexistent stray ?id="
312
+ end
313
+
314
+ test "unregistered resource name 404s before the authorize hook ever runs" do
315
+ hook_called = false
316
+ with_authorize(->(**) { hook_called = true; false }) do
317
+ get "/internal/admin_suite/ops/totally_unregistered_things/search", params: { q: "x" }
318
+ end
319
+ refute hook_called, "authorize hook must not run for an unregistered resource name"
320
+ end
321
+
322
+ test "a very long q does not crash and does not leak the table" do
323
+ get COMPANIES_SEARCH, params: { q: "a" * 10_000 }
324
+ assert_response :success
325
+ assert_equal [], JSON.parse(response.body)
326
+ end
327
+
328
+ test "SQL metacharacters in q do not crash and are treated as a literal search term" do
329
+ get COMPANIES_SEARCH, params: { q: "'; DROP TABLE companies; --" }
330
+ assert_response :success
331
+ assert_equal [], JSON.parse(response.body)
332
+ end
333
+
334
+ test "render_searchable_select resolves field resource: to the gem's search endpoint" do
335
+ get "/internal/admin_suite/ops/searchable_select_deals/new"
336
+ assert_response :success
337
+ assert_includes response.body, "/internal/admin_suite/ops/searchable_select_companies/search"
338
+ end
339
+
340
+ test "a String collection: still overrides the default resource: URL, unchanged" do
341
+ get "/internal/admin_suite/ops/searchable_select_deals/new"
342
+ assert_response :success
343
+ assert_includes response.body, "/custom/vendor/search"
344
+ end
345
+
346
+ # Pins requirement #1 (authentication) with an actual test, not trace
347
+ # only -- this controller relies on ApplicationController's
348
+ # `admin_suite_authenticate!` before_action, and nothing here re-proves
349
+ # that for this specific action. Mirrors
350
+ # authentication_test.rb's "unconfigured auth fails closed with 403".
351
+ test "unconfigured auth fails closed with 403 on /search" do
352
+ saved_allow = AdminSuite.config.allow_unauthenticated
353
+ saved_strategy = AdminSuite.config.auth_strategy
354
+ saved_authenticate = AdminSuite.config.authenticate
355
+
356
+ AdminSuite.config.allow_unauthenticated = false
357
+ AdminSuite.config.auth_strategy = nil
358
+ AdminSuite.config.authenticate = nil
359
+
360
+ get COMPANIES_SEARCH, params: { q: "Acme" }
361
+ assert_response :forbidden
362
+ ensure
363
+ AdminSuite.config.allow_unauthenticated = saved_allow
364
+ AdminSuite.config.auth_strategy = saved_strategy
365
+ AdminSuite.config.authenticate = saved_authenticate
366
+ end
367
+ end
368
+ end
@@ -0,0 +1,195 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+
5
+ # Exercises `hide_blank:` on `panel`/`section` `fields:` rows. Mirrors the
6
+ # `ReadOnlyResourceFixtures::Widget` shape (test/integration/read_only_resource_test.rb)
7
+ # so the show route resolves through the ordinary numeric-id `klass.find` path,
8
+ # not the slug/uuid/token fallback.
9
+ module HideBlankFixtures
10
+ class Widget
11
+ extend ActiveModel::Naming
12
+
13
+ attr_reader :id
14
+
15
+ def initialize(id: 1)
16
+ @id = id
17
+ end
18
+
19
+ def self.all
20
+ [ new ]
21
+ end
22
+
23
+ def self.column_names
24
+ %w[id]
25
+ end
26
+
27
+ def self.primary_key
28
+ "id"
29
+ end
30
+
31
+ def self.columns_hash
32
+ { "id" => Struct.new(:type).new(:integer) }
33
+ end
34
+
35
+ def self.find(id)
36
+ raise ActiveRecord::RecordNotFound unless id.to_s == "1"
37
+
38
+ new
39
+ end
40
+
41
+ def to_param
42
+ id.to_s
43
+ end
44
+
45
+ def attributes
46
+ { "id" => id }
47
+ end
48
+
49
+ # Non-blank control value -- always kept, hide_blank or not.
50
+ def title
51
+ "Widget One"
52
+ end
53
+
54
+ def blank_string
55
+ ""
56
+ end
57
+
58
+ def whitespace_string
59
+ " "
60
+ end
61
+
62
+ def nil_field
63
+ nil
64
+ end
65
+
66
+ def empty_array
67
+ []
68
+ end
69
+
70
+ def empty_hash
71
+ {}
72
+ end
73
+
74
+ def false_flag
75
+ false
76
+ end
77
+
78
+ def zero_count
79
+ 0
80
+ end
81
+
82
+ def zero_float
83
+ 0.0
84
+ end
85
+ end
86
+ end
87
+
88
+ module Admin
89
+ module Resources
90
+ class HideBlankWidgetResource < Admin::Base::Resource
91
+ model HideBlankFixtures::Widget
92
+ portal :ops
93
+ section :observability
94
+
95
+ FIELDS = %i[
96
+ title blank_string whitespace_string nil_field
97
+ empty_array empty_hash false_flag zero_count zero_float
98
+ ].freeze
99
+
100
+ show do
101
+ sidebar do
102
+ panel :sidebar_hidden, title: "Sidebar hidden", fields: FIELDS, hide_blank: true
103
+ panel :sidebar_default, title: "Sidebar default", fields: FIELDS
104
+ end
105
+
106
+ section :main_hidden, title: "Main hidden", fields: FIELDS, hide_blank: true
107
+ section :main_default, title: "Main default", fields: FIELDS
108
+ end
109
+ end
110
+ end
111
+ end
112
+
113
+ module AdminSuite
114
+ class ShowHideBlankTest < ActionDispatch::IntegrationTest
115
+ PATH = "/internal/admin_suite/ops/hide_blank_widgets/1"
116
+
117
+ test "hide_blank: true hides nil, empty string, empty array, and empty hash" do
118
+ get PATH
119
+ assert_response :success
120
+
121
+ hidden_section = response.body[/Main hidden.*?(?=Main default)/m]
122
+ refute_nil hidden_section, "expected to find the 'Main hidden' panel before 'Main default' in the body"
123
+
124
+ refute_includes hidden_section, "Nil field"
125
+ refute_includes hidden_section, "Blank string"
126
+ refute_includes hidden_section, "Empty array"
127
+ refute_includes hidden_section, "Empty hash"
128
+ end
129
+
130
+ test "hide_blank: true keeps false, 0, 0.0, and whitespace-only strings" do
131
+ get PATH
132
+ assert_response :success
133
+
134
+ hidden_section = response.body[/Main hidden.*?(?=Main default)/m]
135
+ refute_nil hidden_section
136
+
137
+ assert_includes hidden_section, "False flag"
138
+ assert_includes hidden_section, "Zero count"
139
+ assert_includes hidden_section, "Zero float"
140
+ assert_includes hidden_section, "Whitespace string"
141
+ # And the boolean must still render as the meaningful "No" state, not
142
+ # silently vanish.
143
+ assert_includes hidden_section, "No"
144
+ end
145
+
146
+ test "hide_blank: true keeps genuinely non-blank fields" do
147
+ get PATH
148
+ assert_response :success
149
+
150
+ hidden_section = response.body[/Main hidden.*?(?=Main default)/m]
151
+ assert_includes hidden_section, "Title"
152
+ assert_includes hidden_section, "Widget One"
153
+ end
154
+
155
+ test "default (no hide_blank option) hides nothing -- every label row still renders" do
156
+ get PATH
157
+ assert_response :success
158
+
159
+ default_section = response.body[/Main default.*?(?=<\/html>)/m]
160
+ refute_nil default_section
161
+
162
+ %w[Title Blank\ string Whitespace\ string Nil\ field Empty\ array Empty\ hash False\ flag Zero\ count Zero\ float].each do |label|
163
+ assert_includes default_section, label, "expected label '#{label}' to render in the default (no hide_blank) panel"
164
+ end
165
+ end
166
+
167
+ test "hide_blank: true on a sidebar panel hides the same blank fields as main" do
168
+ get PATH
169
+ assert_response :success
170
+
171
+ sidebar_hidden = response.body[/Sidebar hidden.*?(?=Sidebar default)/m]
172
+ refute_nil sidebar_hidden
173
+
174
+ refute_includes sidebar_hidden, "Nil field"
175
+ refute_includes sidebar_hidden, "Blank string"
176
+ refute_includes sidebar_hidden, "Empty array"
177
+ refute_includes sidebar_hidden, "Empty hash"
178
+ assert_includes sidebar_hidden, "False flag"
179
+ assert_includes sidebar_hidden, "Zero count"
180
+ assert_includes sidebar_hidden, "Zero float"
181
+ end
182
+
183
+ test "default sidebar panel (no hide_blank) hides nothing" do
184
+ get PATH
185
+ assert_response :success
186
+
187
+ sidebar_default = response.body[/Sidebar default.*?(?=<div class="bg-white)/m] || response.body[/Sidebar default.*\z/m]
188
+ refute_nil sidebar_default
189
+
190
+ %w[Nil\ field Blank\ string Empty\ array Empty\ hash].each do |label|
191
+ assert_includes sidebar_default, label
192
+ end
193
+ end
194
+ end
195
+ end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+
5
+ # Final review Finding 3: the gem hard-depends on turbo-rails (`turbo_stream`
6
+ # in `ResourcesController#toggle`, `turbo_frame_tag` throughout the resource
7
+ # views) but never declared it in the gemspec. Before that dependency is
8
+ # declared, this test exercises the exact path the review flagged as broken
9
+ # in a turbo-less host: `format.turbo_stream` inside `respond_to` raises the
10
+ # moment it's evaluated, because the `:turbo_stream` MIME type is unregistered
11
+ # without turbo-rails loaded. See `test/test_helper.rb`'s (now removed)
12
+ # `TurboFrameTestHelper`, which only ever patched `turbo_frame_tag` -- it
13
+ # never made `turbo_stream`/the MIME type real, so this branch was untestable
14
+ # until the real dependency was declared.
15
+ module ToggleFixtures
16
+ class Widget
17
+ extend ActiveModel::Naming
18
+
19
+ attr_reader :id
20
+ attr_accessor :active
21
+
22
+ def initialize(id: 1, active: false)
23
+ @id = id
24
+ @active = active
25
+ end
26
+
27
+ def self.all
28
+ ReadOnlyResourceFixtures::Relation.new([ new ])
29
+ end
30
+
31
+ def self.column_names
32
+ %w[id active]
33
+ end
34
+
35
+ def self.primary_key
36
+ "id"
37
+ end
38
+
39
+ def self.columns_hash
40
+ { "id" => Struct.new(:type).new(:integer) }
41
+ end
42
+
43
+ def self.find(id)
44
+ return new(id: 1, active: false) if id.to_s == "1"
45
+
46
+ raise ActiveRecord::RecordNotFound
47
+ end
48
+
49
+ def to_param
50
+ id.to_s
51
+ end
52
+
53
+ # `dom_id` (via `ActionView::RecordIdentifier#record_key_for_dom_id`)
54
+ # calls `to_key` on the record -- `extend ActiveModel::Naming` alone
55
+ # only supplies `model_name`, not `to_key`.
56
+ def to_key
57
+ [ id ]
58
+ end
59
+
60
+ def update!(attrs)
61
+ attrs.each { |key, value| public_send("#{key}=", value) }
62
+ true
63
+ end
64
+ end
65
+ end
66
+
67
+ module Admin
68
+ module Resources
69
+ class ToggleWidgetResource < Admin::Base::Resource
70
+ model ToggleFixtures::Widget
71
+ portal :ops
72
+ section :observability
73
+
74
+ index do
75
+ columns do
76
+ column :active, type: :toggle
77
+ end
78
+ end
79
+ end
80
+ end
81
+ end
82
+
83
+ module AdminSuite
84
+ class ToggleTest < ActionDispatch::IntegrationTest
85
+ include ActionView::RecordIdentifier
86
+
87
+ BASE_PATH = "/internal/admin_suite/ops/toggle_widgets"
88
+
89
+ test "toggle's turbo_stream branch replaces the toggle cell with the flipped state" do
90
+ post "#{BASE_PATH}/1/toggle",
91
+ params: { field: "active" },
92
+ headers: { "Accept" => "text/vnd.turbo-stream.html" }
93
+
94
+ assert_response :success
95
+ assert_equal "text/vnd.turbo-stream.html", response.media_type
96
+
97
+ target = dom_id(ToggleFixtures::Widget.new(id: 1), "active_toggle")
98
+ assert_includes response.body, "<turbo-stream"
99
+ assert_includes response.body, %(action="replace")
100
+ assert_includes response.body, %(target="#{target}")
101
+ # The record started with `active: false`; the toggle flips it to
102
+ # `true` before rendering, so the replaced cell must reflect "on".
103
+ assert_includes response.body, "is-on"
104
+ end
105
+ end
106
+ end