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,289 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+
5
+ # Self-contained fixtures, following the pattern established in
6
+ # association_linking_test.rb/pagination_and_stats_test.rb: this file must
7
+ # work in isolation under `rake test TEST=test/integration/index_table_test.rb`,
8
+ # so it defines everything it needs rather than depending on fixtures that
9
+ # live only in a sibling test file. `LinkingFixtures::Company` is the one
10
+ # exception -- it lives in test_helper.rb (loaded for every run) precisely
11
+ # so tasks like this one can reuse it (see test_helper.rb's comment above
12
+ # it).
13
+ module IndexTableFixtures
14
+ # Two rows: one fully populated (including a present `belongs_to`-shaped
15
+ # association), one deliberately sparse (nil scalar, nil association) --
16
+ # covers item 5 (nil -> em dash) and Task 3's non-regression (a present
17
+ # association still renders as a link) in the same fixture.
18
+ class Widget
19
+ extend ActiveModel::Naming
20
+
21
+ attr_reader :id, :name, :count, :status, :company
22
+
23
+ def initialize(id:, name:, count:, status:, company:)
24
+ @id = id
25
+ @name = name
26
+ @count = count
27
+ @status = status
28
+ @company = company
29
+ end
30
+
31
+ ROWS = [
32
+ new(id: 1, name: "Alpha", count: 5, status: "ok", company: LinkingFixtures::Company.new),
33
+ new(id: 2, name: "Beta", count: nil, status: nil, company: nil)
34
+ ]
35
+
36
+ def self.all = ReadOnlyResourceFixtures::Relation.new(ROWS)
37
+ def self.column_names = %w[id name count status]
38
+ def self.primary_key = "id"
39
+ def self.columns_hash = { "id" => Struct.new(:type).new(:integer) }
40
+ def self.find(id) = ROWS.find { |w| w.to_param == id.to_s }
41
+ def to_param = id.to_s
42
+ def attributes = { "id" => id, "name" => name, "count" => count, "status" => status }
43
+ end
44
+
45
+ # A real slicing relation (unlike `ReadOnlyResourceFixtures::Relation`,
46
+ # whose `#offset`/`#limit` are no-ops returning self -- see
47
+ # test-harness fact #8). `Pagy::Backend#pagy_get_items` calls
48
+ # `collection.offset(pagy.offset).limit(pagy.limit)`, so this must
49
+ # actually slice for the per-page tests to prove anything.
50
+ class SlicingRelation
51
+ include Enumerable
52
+
53
+ def initialize(records, offset: 0)
54
+ @records = records
55
+ @offset = offset
56
+ end
57
+
58
+ def each(&block) = @records.each(&block)
59
+ def count(*) = @records.length
60
+ def offset(n) = SlicingRelation.new(@records, offset: n)
61
+ def limit(n) = @records[@offset, n] || []
62
+ end
63
+
64
+ class PagedWidget
65
+ extend ActiveModel::Naming
66
+
67
+ attr_reader :id
68
+
69
+ def initialize(id:) = @id = id
70
+
71
+ ALL_ROWS = (1..150).map { |n| new(id: n) }
72
+
73
+ def self.all = SlicingRelation.new(ALL_ROWS)
74
+ def self.column_names = %w[id]
75
+ def self.primary_key = "id"
76
+ def self.columns_hash = { "id" => Struct.new(:type).new(:integer) }
77
+ def self.find(id) = ALL_ROWS.find { |w| w.to_param == id.to_s }
78
+ def to_param = id.to_s
79
+ def attributes = { "id" => id }
80
+ end
81
+ end
82
+
83
+ module Admin
84
+ module Resources
85
+ class IndexTableWidgetResource < Admin::Base::Resource
86
+ model IndexTableFixtures::Widget
87
+ portal :ops
88
+ section :observability
89
+
90
+ index do
91
+ # A filter is required for the sidebar (and thus the per-page
92
+ # selector, which lives inside that same form) to render at all --
93
+ # see `index.html.erb`'s `if index_config&.filters_list&.any? || ...`
94
+ # guard.
95
+ filters { filter :name, type: :text }
96
+ columns do
97
+ column :name, class: "font-mono"
98
+ column :count, align: :right
99
+ column :status, align: :diagonal
100
+ column :company
101
+ end
102
+ paginate 10
103
+ end
104
+ end
105
+
106
+ class IndexTablePagedWidgetResource < Admin::Base::Resource
107
+ model IndexTableFixtures::PagedWidget
108
+ portal :ops
109
+ section :observability
110
+
111
+ index do
112
+ columns { column :id }
113
+ # Deliberately distinct from the hardcoded 25 default and from any
114
+ # of the per_page values under test (10, 50, 100), so a test that
115
+ # asserts "10 rows" can only be passing because the DSL fallback
116
+ # kicked in, not by coincidence with some other default.
117
+ paginate 10
118
+ end
119
+ end
120
+ end
121
+ end
122
+
123
+ module AdminSuite
124
+ class IndexTableTest < ActionDispatch::IntegrationTest
125
+ # Item 1: sticky header.
126
+ test "the index thead carries the sticky header classes" do
127
+ get "/internal/admin_suite/ops/index_table_widgets"
128
+ assert_response :success
129
+ assert_match %r{<thead[^>]*\bsticky\b[^>]*\btop-0\b[^>]*\bz-10\b[^>]*>}, response.body
130
+ end
131
+
132
+ # `sticky top-0` only actually pins the header if its nearest scroll
133
+ # container ancestor genuinely scrolls internally. `overflow-x: auto`
134
+ # alone forces the browser to compute `overflow-y` as `auto` too (per
135
+ # the CSS Overflow spec's "visible becomes auto when the other axis
136
+ # isn't visible" rule), making the wrapper div a sticky containing
137
+ # block -- but with no bounded height, that div never scrolls, so the
138
+ # header has nothing to stick against and just scrolls away with the
139
+ # page. This asserts the wrapper is *also* given a bounded height and
140
+ # `overflow-y-auto`, so a future edit that drops the height silently
141
+ # re-breaks sticky (a class-presence-only test on `<thead>` would stay
142
+ # green even if the header stopped sticking) and fails here instead.
143
+ test "the table's scroll wrapper is bounded so the sticky header has something to stick against" do
144
+ get "/internal/admin_suite/ops/index_table_widgets"
145
+ assert_response :success
146
+ assert_match(
147
+ %r{<div class="[^"]*overflow-x-auto[^"]*overflow-y-auto[^"]*max-h-\[70vh\][^"]*"},
148
+ response.body
149
+ )
150
+ end
151
+
152
+ # Item 2: row click wiring, via the already-existing ClickActionsController.
153
+ test "each row wires the click-actions controller to its own show path" do
154
+ get "/internal/admin_suite/ops/index_table_widgets"
155
+ assert_response :success
156
+
157
+ assert_match(/<tr[^>]*data-controller="admin-suite--click-actions"[^>]*>/, response.body)
158
+ # ERB HTML-escapes the `>` in the Stimulus action descriptor, so the
159
+ # attribute renders as `click-&gt;...`, not a literal `->`.
160
+ assert_match(/<tr[^>]*data-action="click-&gt;admin-suite--click-actions#navigate"[^>]*>/, response.body)
161
+ assert_match(
162
+ %r{<tr[^>]*data-admin-suite--click-actions-url-value="[^"]*index_table_widgets/1"[^>]*>},
163
+ response.body
164
+ )
165
+ end
166
+
167
+ # Item 3: per-page selector + server-side clamp.
168
+ test "the filter form offers a 25/50/100 per_page selector" do
169
+ get "/internal/admin_suite/ops/index_table_widgets"
170
+ assert_response :success
171
+ assert_match(/<select[^>]*name="per_page"[^>]*>/, response.body)
172
+ assert_match(/<option[^>]*value="25"/, response.body)
173
+ assert_match(/<option[^>]*value="50"/, response.body)
174
+ assert_match(/<option[^>]*value="100"/, response.body)
175
+ end
176
+
177
+ # Counts `<tr>` inside `<tbody>` only, deliberately independent of item
178
+ # 2's row-click markup (which lands in the same `<tr>` tags) -- so a
179
+ # per_page test failure can never be secretly caused by row-click not
180
+ # being wired yet.
181
+ def row_count(body)
182
+ tbody = body[%r{<tbody.*?</tbody>}m]
183
+ tbody ? tbody.scan(/<tr[ >]/).size : 0
184
+ end
185
+
186
+ test "no per_page param falls back to the DSL's paginate(n) value" do
187
+ get "/internal/admin_suite/ops/index_table_paged_widgets"
188
+ assert_response :success
189
+ assert_equal 10, row_count(response.body)
190
+ end
191
+
192
+ test "a valid per_page renders that many rows" do
193
+ get "/internal/admin_suite/ops/index_table_paged_widgets", params: { per_page: 50 }
194
+ assert_response :success
195
+ assert_equal 50, row_count(response.body)
196
+ end
197
+
198
+ test "per_page above the max clamps to 100, not the requested amount" do
199
+ get "/internal/admin_suite/ops/index_table_paged_widgets", params: { per_page: 999_999 }
200
+ assert_response :success
201
+ assert_equal 100, row_count(response.body)
202
+ end
203
+
204
+ test "per_page=0 falls back to the DSL value instead of an empty/unbounded page" do
205
+ get "/internal/admin_suite/ops/index_table_paged_widgets", params: { per_page: 0 }
206
+ assert_response :success
207
+ assert_equal 10, row_count(response.body)
208
+ end
209
+
210
+ test "a negative per_page falls back to the DSL value" do
211
+ get "/internal/admin_suite/ops/index_table_paged_widgets", params: { per_page: -1 }
212
+ assert_response :success
213
+ assert_equal 10, row_count(response.body)
214
+ end
215
+
216
+ test "a non-numeric per_page falls back to the DSL value" do
217
+ get "/internal/admin_suite/ops/index_table_paged_widgets", params: { per_page: "abc" }
218
+ assert_response :success
219
+ assert_equal 10, row_count(response.body)
220
+ end
221
+
222
+ test "an array-shaped per_page (per_page[]=1) falls back to the DSL value" do
223
+ get "/internal/admin_suite/ops/index_table_paged_widgets", params: { per_page: [ "1" ] }
224
+ assert_response :success
225
+ assert_equal 10, row_count(response.body)
226
+ end
227
+
228
+ # Item 4: column alignment + css_class.
229
+ test "align: :right emits text-right on the td" do
230
+ get "/internal/admin_suite/ops/index_table_widgets"
231
+ assert_response :success
232
+ assert_match(%r{<td class="[^"]*\btext-right\b[^"]*">\s*5\s*</td>}, response.body)
233
+ end
234
+
235
+ test "column.css_class (the class: option) reaches the td" do
236
+ get "/internal/admin_suite/ops/index_table_widgets"
237
+ assert_response :success
238
+ assert_match(%r{<td class="[^"]*\bfont-mono\b[^"]*">}, response.body)
239
+ end
240
+
241
+ test "an unknown align value renders successfully with no fabricated text-<value> class" do
242
+ get "/internal/admin_suite/ops/index_table_widgets"
243
+ assert_response :success
244
+ refute_includes response.body, "text-diagonal"
245
+ end
246
+
247
+ # Isolates a single fixture row's `<tr>...</tr>` markup by a distinctive
248
+ # cell value, so nil-cell assertions can target Beta's row specifically
249
+ # instead of matching the first empty-looking `<td>` anywhere on the
250
+ # page (there are several: Alpha's row has none, but a loose regex
251
+ # would not tell the two rows apart).
252
+ def widget_row(body, marker)
253
+ tbody = body[%r{<tbody.*?</tbody>}m]
254
+ tbody.scan(%r{<tr.*?</tr>}m).find { |r| r.include?(marker) }
255
+ end
256
+
257
+ # Item 5: nil cells render an em dash, and Task 3's association links
258
+ # are not regressed by that change.
259
+ test "a nil scalar column renders an em dash, not a blank cell" do
260
+ get "/internal/admin_suite/ops/index_table_widgets"
261
+ assert_response :success
262
+ beta_row = widget_row(response.body, "Beta")
263
+ refute_nil beta_row, "expected to find Beta's row"
264
+ # Beta's `count` is nil -- the fallback branch must turn that into an
265
+ # em dash instead of the empty string it renders today.
266
+ assert_match(%r{<td[^>]*>\s*—\s*</td>}, beta_row)
267
+ end
268
+
269
+ test "a nil association column renders an em dash, not a blank cell" do
270
+ get "/internal/admin_suite/ops/index_table_widgets"
271
+ assert_response :success
272
+ beta_row = widget_row(response.body, "Beta")
273
+ refute_nil beta_row, "expected to find Beta's row"
274
+ # Beta's `company` is nil (Task 3 deliberately left nil association
275
+ # handling to this task). Count how many dash-only cells the row has:
276
+ # count and status are also nil, so there must be at least 3 (not 2),
277
+ # proving company's cell got the dash too rather than staying blank.
278
+ dash_cells = beta_row.scan(%r{<td[^>]*>\s*—\s*</td>}).size
279
+ assert_equal 3, dash_cells,
280
+ "expected 3 dash cells (count, status, company all nil) in Beta's row, got #{dash_cells}"
281
+ end
282
+
283
+ test "a present association still renders as a link (Task 3 non-regression)" do
284
+ get "/internal/admin_suite/ops/index_table_widgets"
285
+ assert_response :success
286
+ assert_match %r{<a[^>]+href="[^"]*linking_companies/7"[^>]*>\s*Acme Corp\s*</a>}, response.body
287
+ end
288
+ end
289
+ end
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+
5
+ # A minimal ActiveModel-backed fixture with two markdown fields, registered
6
+ # as a real resource so we can render an actual /new page through the full
7
+ # stack and assert the vendored EasyMDE tags appear (not just that the files
8
+ # exist on disk, and not just that a markdown-less page omits them).
9
+ #
10
+ # Per Task 9's ledger: `Admin::Base::Resource` subclasses register into a
11
+ # process-global registry via `Class#inherited`, which fires once at class
12
+ # definition (here, at file load) and never again -- so this is safe to
13
+ # define at the top level like `ReadOnlyResourceFixtures`/`ReadOnlyWidgetResource`
14
+ # in test/integration/read_only_resource_test.rb, and doesn't interact badly
15
+ # with the nav-rebuild hazard documented there (this resource is never wiped
16
+ # or reloaded mid-run).
17
+ module LayoutAssetsFixtures
18
+ class MarkdownWidget
19
+ extend ActiveModel::Naming
20
+ include ActiveModel::Conversion
21
+
22
+ attr_accessor :id, :body, :notes
23
+
24
+ def initialize(id: nil, body: nil, notes: nil)
25
+ @id = id
26
+ @body = body
27
+ @notes = notes
28
+ end
29
+
30
+ def self.column_names
31
+ %w[id body notes]
32
+ end
33
+
34
+ def persisted?
35
+ !id.nil?
36
+ end
37
+
38
+ def new_record?
39
+ !persisted?
40
+ end
41
+
42
+ # render_form_field unconditionally calls `resource.errors[field.name]`
43
+ # (to add the error border class / message) for every field type; no
44
+ # test here exercises a validation error, so a Hash defaulting to `[]`
45
+ # is sufficient (same rationale as form_field_renderer_test.rb's Record).
46
+ def errors
47
+ Hash.new([])
48
+ end
49
+ end
50
+ end
51
+
52
+ module Admin
53
+ module Resources
54
+ class MarkdownWidgetResource < Admin::Base::Resource
55
+ model LayoutAssetsFixtures::MarkdownWidget
56
+ portal :ops
57
+ section :observability
58
+
59
+ # Two markdown fields on the same form: exercises the
60
+ # `content_for?(:easymde_assets)` dedup guard in
61
+ # AdminSuite::UI::FieldRendererRegistry's :markdown handler.
62
+ form do
63
+ field :body, type: :markdown
64
+ field :notes, type: :markdown
65
+ end
66
+ end
67
+ end
68
+ end
69
+
70
+ module AdminSuite
71
+ class LayoutAssetsTest < ActionDispatch::IntegrationTest
72
+ test "the admin layout requests no third-party CDN assets" do
73
+ get "/internal/admin_suite"
74
+ assert_response :success
75
+ refute_includes response.body, "cdn.jsdelivr.net"
76
+ refute_match(%r{<script[^>]+src="https?://(?!localhost)}, response.body)
77
+ end
78
+
79
+ test "EasyMDE ships as a vendored engine asset" do
80
+ assert AdminSuite::Engine.root.join("app/assets/vendor/easymde.min.js").exist?
81
+ assert AdminSuite::Engine.root.join("app/assets/vendor/easymde.min.css").exist?
82
+ end
83
+
84
+ test "the markdown controller bounds its editor-availability retry" do
85
+ js = AdminSuite::Engine.root.join("app/javascript/controllers/admin_suite/markdown_editor_controller.js").read
86
+ assert_match(/attempts|retries|maxWait/i, js)
87
+ end
88
+
89
+ test "a page rendering a markdown field loads the vendored EasyMDE assets, and still no CDN" do
90
+ get "/internal/admin_suite/ops/markdown_widgets/new"
91
+ assert_response :success
92
+
93
+ refute_includes response.body, "cdn.jsdelivr.net"
94
+ assert_match(%r{<link[^>]+href="[^"]*vendor/easymde\.min[^"]*\.css"}, response.body)
95
+ assert_match(%r{<script[^>]+src="[^"]*vendor/easymde\.min[^"]*\.js"}, response.body)
96
+ end
97
+
98
+ test "multiple markdown fields on one form emit the EasyMDE assets exactly once" do
99
+ get "/internal/admin_suite/ops/markdown_widgets/new"
100
+ assert_response :success
101
+
102
+ assert_equal 1, response.body.scan(%r{<link[^>]+href="[^"]*vendor/easymde\.min[^"]*\.css"}).size
103
+ assert_equal 1, response.body.scan(%r{<script[^>]+src="[^"]*vendor/easymde\.min[^"]*\.js"}).size
104
+ end
105
+
106
+ test "a page without a markdown field never references EasyMDE" do
107
+ get "/internal/admin_suite"
108
+ assert_response :success
109
+ refute_includes response.body, "vendor/easymde.min"
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+
5
+ module AdminSuite
6
+ class NavigationSectionsTest < ActionDispatch::IntegrationTest
7
+ setup do
8
+ AdminSuite::PortalRegistry.reset!
9
+ AdminSuite.portal :ops do
10
+ label "Ops"
11
+ section :zzz_last do
12
+ label "Aaa Displayed First"
13
+ order 1
14
+ end
15
+ section :observability do
16
+ label "Observability"
17
+ order 50
18
+ end
19
+ end
20
+ end
21
+
22
+ teardown { AdminSuite::PortalRegistry.reset! }
23
+
24
+ test "declared sections use their label and order, not alphabetical keys" do
25
+ get "/internal/admin_suite/ops"
26
+ assert_response :success
27
+ first = response.body.index("Aaa Displayed First")
28
+ second = response.body.index("Observability")
29
+ assert first, "declared section label missing"
30
+ assert second, "second section label missing"
31
+ assert first < second, "sections must honour declared order, not label sort"
32
+ end
33
+
34
+ test "undeclared sections still auto-synthesize a humanized label" do
35
+ AdminSuite::PortalRegistry.reset!
36
+ get "/internal/admin_suite/ops"
37
+ assert_response :success
38
+ assert_includes response.body, "Observability" # from ReadOnlyWidgetResource's `section :observability`
39
+ end
40
+
41
+ # `:zzz_last` (declared in `setup` above) has no resources registered
42
+ # under it anywhere in the suite, unlike `:observability`. Before this
43
+ # fix, `_sidebar.html.erb` rendered a bare section label with nothing
44
+ # underneath for a section like this; `portals/show.html.erb` already
45
+ # showed "No resources in this section yet." for the same case, so the
46
+ # sidebar was the odd one out.
47
+ test "the sidebar shows the no-resources message for a declared but empty section" do
48
+ get "/internal/admin_suite/ops"
49
+ assert_response :success
50
+
51
+ # Scoped to the sidebar itself: `portals/show.html.erb`'s own fallback
52
+ # section list also renders "No resources in this section yet." for
53
+ # the same empty section, so an unscoped `assert_includes` on the full
54
+ # body wouldn't actually prove the *sidebar* (as opposed to the main
55
+ # content) says it.
56
+ sidebar = Nokogiri::HTML(response.body).at_css(".admin-suite-sidebar")
57
+ assert sidebar, "expected to find the sidebar"
58
+ assert_includes sidebar.text, "Aaa Displayed First"
59
+ assert_includes sidebar.text, "No resources in this section yet."
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,152 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+
5
+ # Self-contained fixture, following the pattern established in
6
+ # authorization_test.rb/read_only_resource_test.rb: each integration test
7
+ # file defines its own model + resource rather than depending on another
8
+ # test file's fixture being loaded. Rake's TEST= support (rake/testtask.rb)
9
+ # replaces the whole file list with just the one file requested, so a
10
+ # fixture defined only in a sibling test file (e.g. ReadOnlyWidgetResource
11
+ # in read_only_resource_test.rb) does not exist when this file is run in
12
+ # isolation with `rake test TEST=test/integration/pagination_and_stats_test.rb`
13
+ # (the exact command this task's brief calls for) — that dependency was a
14
+ # plan-authored test defect, fixed here rather than in the implementation.
15
+ module PaginationStatsFixtures
16
+ class Widget
17
+ extend ActiveModel::Naming
18
+
19
+ attr_reader :id, :name
20
+
21
+ def initialize(id: 1, name: "Observed widget")
22
+ @id = id
23
+ @name = name
24
+ end
25
+
26
+ def self.all = ReadOnlyResourceFixtures::Relation.new((1..25).map { |n| new(id: n, name: "Observed widget #{n}") })
27
+ def self.column_names = %w[id name]
28
+ def self.primary_key = "id"
29
+ def self.columns_hash = { "id" => Struct.new(:type).new(:integer) }
30
+
31
+ def self.find(id)
32
+ raise ActiveRecord::RecordNotFound unless id.to_s == "1"
33
+
34
+ new
35
+ end
36
+
37
+ def to_param = id.to_s
38
+ def attributes = { "id" => id, "name" => name }
39
+
40
+ # A plain Array, not ReadOnlyResourceFixtures::Relation: render_association_section
41
+ # slices it via `Array.wrap(associated)[pagy.offset, per_page]` (the
42
+ # branch taken when the association doesn't respond to :offset), which
43
+ # exercises real pagination slicing rather than the Relation stand-in's
44
+ # no-op #offset/#limit.
45
+ def parts
46
+ (1..5).map { |n| Part.new(n) }
47
+ end
48
+ end
49
+
50
+ # Plain (non-ActiveRecord) associated record: exercises
51
+ # render_association_section's pagination path, the sole surviving caller
52
+ # of the deleted pagy_prev_link/pagy_next_link/pagy_page_links/
53
+ # render_pagy_series_item/render_association_pagination helpers.
54
+ class Part
55
+ attr_reader :id, :name
56
+
57
+ def initialize(id)
58
+ @id = id
59
+ @name = "Part #{id}"
60
+ end
61
+ end
62
+ end
63
+
64
+ module Admin
65
+ module Resources
66
+ class PaginationStatsWidgetResource < Admin::Base::Resource
67
+ model PaginationStatsFixtures::Widget
68
+ portal :ops
69
+ section :observability
70
+ read_only
71
+
72
+ index do
73
+ columns { column :name }
74
+ paginate 10
75
+ stats do
76
+ stat :total, -> { 7 }, color: :indigo
77
+ end
78
+ end
79
+
80
+ show do
81
+ section :parts, association: :parts, paginate: true, per_page: 2
82
+ end
83
+ end
84
+ end
85
+ end
86
+
87
+ module AdminSuite
88
+ class PaginationAndStatsTest < ActionDispatch::IntegrationTest
89
+ test "index stats render without dynamic tailwind color classes" do
90
+ get "/internal/admin_suite/ops/pagination_stats_widgets"
91
+ assert_response :success
92
+ refute_match(/class="[^"]*text-\{/, response.body)
93
+ # A real stat, with a real (new) color, rendered end to end through
94
+ # AdminSuite::UI::PanelDefinition + admin_suite/panels/_stat — not
95
+ # just a source-text check that the dynamic interpolation is gone.
96
+ assert_includes response.body, "text-indigo-700"
97
+ assert_includes response.body, ">7<"
98
+ end
99
+
100
+ test "the index stats markup comes from the shared stat partial" do
101
+ source = AdminSuite::Engine.root.join("app/views/admin_suite/resources/index.html.erb").read
102
+ refute_includes source, 'text-<%= color %>-600'
103
+ assert_includes source, "admin_suite/panels/stat"
104
+ end
105
+
106
+ test "pagination markup is a single shared partial" do
107
+ index = AdminSuite::Engine.root.join("app/views/admin_suite/resources/index.html.erb").read
108
+ helper = AdminSuite::Engine.root.join("app/helpers/admin_suite/base_helper.rb").read
109
+ assert_includes index, "admin_suite/shared/pagination"
110
+ refute_includes helper, "def pagy_prev_link"
111
+ refute_includes helper, "def render_pagy_series_item"
112
+ end
113
+
114
+ # The brief's original version of this test rendered the partial in
115
+ # isolation via `ApplicationController.render(partial:, locals:)`. That
116
+ # bare renderer has no routed request, so `params` carries no
117
+ # :controller/:action, and the partial's `url_for(params.permit!.merge(...))`
118
+ # calls (verbatim from the index view, per the brief's Step 3) raise
119
+ # `ActionController::UrlGenerationError: No route matches`. That is a
120
+ # second plan-authored test defect: fixed here by exercising the partial
121
+ # through a real routed request, which is how both callers (index,
122
+ # association panels) actually invoke it.
123
+ test "the shared pagination partial renders prev, next and the series" do
124
+ get "/internal/admin_suite/ops/pagination_stats_widgets", params: { page: 2 }
125
+ assert_response :success
126
+ assert_includes response.body, "Prev"
127
+ assert_includes response.body, "Next"
128
+ assert_includes response.body, "3"
129
+ end
130
+
131
+ test "association panel pagination renders through the same shared partial" do
132
+ get "/internal/admin_suite/ops/pagination_stats_widgets/1", params: { parts_page: 2 }
133
+ assert_response :success
134
+ assert_includes response.body, "Prev"
135
+ assert_includes response.body, "Next"
136
+ # Richer, index-derived markup that render_association_pagination
137
+ # (now deleted) never rendered: the "Showing X to Y of Z results"
138
+ # summary line (5 parts, 2 per page, page 2 => items 3-4).
139
+ assert_includes response.body, "Showing"
140
+ assert_includes response.body, "results"
141
+ assert_includes response.body, "3"
142
+ assert_includes response.body, "4"
143
+ assert_includes response.body, "5"
144
+ # The part most likely to regress: the per-association page param
145
+ # (association_page_param -> "#{section.association}_page"), not the
146
+ # generic :page the index uses.
147
+ assert_includes response.body, "parts_page=1"
148
+ assert_includes response.body, "parts_page=3"
149
+ refute_includes response.body, "?page=1"
150
+ end
151
+ end
152
+ end