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,136 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+
5
+ module AdminSuite
6
+ class FormFieldRendererTest < ActionView::TestCase
7
+ include AdminSuite::BaseHelper
8
+
9
+ Record = Struct.new(:name, :body, :enabled, :error_messages) do
10
+ # Both a class-level and an instance-level `model_name` are needed:
11
+ # `render_toggle_field`/`render_searchable_select`/`render_multi_select`
12
+ # all call `resource.class.model_name.param_key` (class-level), while
13
+ # `form_with` itself wants the instance-level accessor. (`extend
14
+ # ActiveModel::Naming` would give both via its own delegation, but
15
+ # then redefining `model_name` below to force the short "Record" name
16
+ # -- rather than the fully-qualified nested constant name -- clashes
17
+ # with that delegation and triggers a "method redefined" warning.
18
+ # Defining both explicitly avoids it.) The pre-existing instance-level
19
+ # `model_name` never covered the class-level call site because no test
20
+ # here exercised `:toggle`/`:searchable_select`/`:multi_select` before.
21
+ def self.model_name
22
+ @model_name ||= ActiveModel::Name.new(self, nil, "Record")
23
+ end
24
+
25
+ def to_model = self
26
+ def model_name = self.class.model_name
27
+ def persisted? = false
28
+ def to_key = nil
29
+
30
+ # render_form_field unconditionally calls `resource.errors[field.name]`
31
+ # for every field type (to add the error border class / message), so
32
+ # the double needs an `errors` object supporting `[]` -> Array-like
33
+ # (`.any?`, `.first`). Most tests here don't exercise an actual
34
+ # validation error, so a Hash defaulting to `[]` is sufficient; tests
35
+ # that do pass `error_messages` (a plain Hash of field name -> Array of
36
+ # messages) to get real per-field errors without losing the default.
37
+ def errors
38
+ Hash.new([]).merge(error_messages || {})
39
+ end
40
+ end
41
+
42
+ def field(name, type, **overrides)
43
+ Admin::Base::Resource::FieldDefinition.new(
44
+ {
45
+ name: name, type: type, required: false, label: name.to_s.humanize,
46
+ readonly: false, multiple: false, creatable: false, preview: true
47
+ }.merge(overrides)
48
+ )
49
+ end
50
+
51
+ def render_field(type, name: :name, error_messages: nil, **field_overrides)
52
+ record = Record.new("x", "y", true, error_messages)
53
+ html = nil
54
+ form_with(model: record, url: "/", scope: :record) { |f| html = render_form_field(f, field(name, type, **field_overrides), record) }
55
+ html.to_s
56
+ end
57
+
58
+ test "textarea type renders a textarea" do
59
+ assert_includes render_field(:textarea, name: :body), "<textarea"
60
+ end
61
+
62
+ test "email and url types render matching inputs" do
63
+ assert_includes render_field(:email), 'type="email"'
64
+ assert_includes render_field(:url), 'type="url"'
65
+ end
66
+
67
+ test "date and datetime types render matching inputs" do
68
+ assert_includes render_field(:date), 'type="date"'
69
+ assert_includes render_field(:datetime), 'type="datetime-local"'
70
+ end
71
+
72
+ test "markdown type wires the markdown editor controller" do
73
+ assert_includes render_field(:markdown, name: :body), "admin-suite--markdown-editor"
74
+ end
75
+
76
+ test "an unregistered type falls back to a text field" do
77
+ html = render_field(:totally_unknown_type)
78
+ assert_includes html, 'type="text"'
79
+ end
80
+
81
+ test "the label is rendered for every field" do
82
+ assert_includes render_field(:text), "Name"
83
+ end
84
+
85
+ test "a field with a validation error renders the red border and error message" do
86
+ html = render_field(:text, error_messages: { name: [ "is invalid" ] })
87
+ assert_includes html, "border-red-500"
88
+ assert_includes html, "is invalid"
89
+ end
90
+
91
+ # The full set of field types `FieldRendererRegistry` is expected to
92
+ # have registered (see `lib/admin_suite/ui/field_renderer_registry.rb`).
93
+ # Pinned explicitly here, NOT derived from the registry itself: an
94
+ # earlier version of this test iterated `handlers.keys` directly, which
95
+ # cannot detect a deletion -- a deleted key simply stops appearing in
96
+ # the iteration, so the loop body never runs for it and the test still
97
+ # passes with one fewer assertion (verified by deleting the `:toggle`
98
+ # registration: 42 assertions became 41, 0 failures). The
99
+ # size-and-membership assertion in the test below is what actually
100
+ # catches that; a self-referential "iterate the thing you're testing"
101
+ # loop cannot.
102
+ EXPECTED_FIELD_TYPES = %i[
103
+ textarea url email number toggle label select searchable_select
104
+ dependent_select multi_select tags image attachment trix rich_text
105
+ markdown file datetime date time json code text string
106
+ ].freeze
107
+
108
+ # :trix/:rich_text both call `f.rich_text_area`, which only exists once
109
+ # ActionText's FormBuilder extension is loaded -- and per the plan's
110
+ # Test-harness fact #2, this dummy app is deliberately database-free and
111
+ # never requires `action_text/engine`. Confirmed by running the
112
+ # rendering test with them included: `NoMethodError: undefined method
113
+ # 'rich_text_area'`, not a bug in FieldRendererRegistry. There is no way
114
+ # to pin these two types in this harness without pulling in ActionText
115
+ # (and, transitively, ActiveRecord) — out of scope here. Named
116
+ # explicitly (not an implicit skip) so `EXPECTED_FIELD_TYPES.size` and
117
+ # the number of types actually exercised below both stay visible and
118
+ # add up: 24 expected, 2 excluded, 22 rendered.
119
+ UNTESTABLE_WITHOUT_ACTION_TEXT = %i[trix rich_text].freeze
120
+
121
+ test "the registry has exactly the expected set of field types, no more, no fewer" do
122
+ assert_equal EXPECTED_FIELD_TYPES.sort, AdminSuite::UI::FieldRendererRegistry.handlers.keys.sort
123
+ end
124
+
125
+ test "every expected field type except the ActionText-only ones renders non-empty markup" do
126
+ sample_collection = [ [ "One", 1 ], [ "Two", 2 ] ]
127
+ needs_collection = %i[select searchable_select dependent_select multi_select tags]
128
+
129
+ (EXPECTED_FIELD_TYPES - UNTESTABLE_WITHOUT_ACTION_TEXT).each do |type|
130
+ overrides = needs_collection.include?(type) ? { collection: sample_collection } : {}
131
+ html = render_field(type, **overrides)
132
+ assert html.present?, "expected #{type.inspect} to render non-empty markup"
133
+ end
134
+ end
135
+ end
136
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+
5
+ module AdminSuite
6
+ # `format_table_cell` had no direct unit test anywhere in the suite before
7
+ # this file -- it was only exercised indirectly through
8
+ # `association_linking_test.rb`'s AR-focused fixtures. Covers the branches
9
+ # not already pinned there, and specifically the Integer/Float/BigDecimal
10
+ # delimiting added to match `format_show_value` (see
11
+ # `ShowValueFormatterTest#"integers and floats render delimited"`).
12
+ class FormatTableCellTest < ActionView::TestCase
13
+ include AdminSuite::BaseHelper
14
+
15
+ test "nil renders the em dash placeholder" do
16
+ assert_equal "—", format_table_cell(nil)
17
+ end
18
+
19
+ test "booleans render Yes/No" do
20
+ assert_equal "Yes", format_table_cell(true)
21
+ assert_equal "No", format_table_cell(false)
22
+ end
23
+
24
+ test "Time and DateTime render a short date-time" do
25
+ assert_equal "Jan 02, 03:04", format_table_cell(Time.utc(2026, 1, 2, 3, 4))
26
+ assert_equal "Jan 02, 03:04", format_table_cell(DateTime.new(2026, 1, 2, 3, 4))
27
+ end
28
+
29
+ test "Date renders a short date" do
30
+ assert_equal "Jan 02, 2026", format_table_cell(Date.new(2026, 1, 2))
31
+ end
32
+
33
+ test "integers and floats render delimited, matching format_show_value" do
34
+ assert_equal "1,234,567", format_table_cell(1_234_567)
35
+ assert_equal "1,234.5", format_table_cell(1234.5)
36
+ end
37
+
38
+ test "BigDecimal renders delimited rather than in exponential notation" do
39
+ result = format_table_cell(BigDecimal("9876.5"))
40
+ assert_includes result, "9,876"
41
+ refute_includes result, "0.98765e4"
42
+ end
43
+
44
+ test "plain strings are truncated at 50 characters" do
45
+ assert_equal "hello", format_table_cell("hello")
46
+ assert_equal ("x" * 47) + "...", format_table_cell("x" * 60)
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,223 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+
5
+ # Fixtures for the `includes:` DSL option (Task 4, phase 2b). A scope that
6
+ # genuinely supports `#includes` and records/validates the call, so the
7
+ # controller's "apply when the scope responds" and "degrade when
8
+ # `.includes` itself raises" paths are both exercisable end-to-end. The
9
+ # `LinkingFixtures::Deal`/`ReadOnlyResourceFixtures::Relation` pair (no
10
+ # `#includes` at all) already covers the "skip silently" path -- reused
11
+ # below rather than duplicated.
12
+ module IndexIncludesFixtures
13
+ class IncludesCapableRelation
14
+ include Enumerable
15
+
16
+ KNOWN_ASSOCIATIONS = %i[company owner].freeze
17
+
18
+ attr_reader :includes_calls
19
+
20
+ def initialize(records)
21
+ @records = records
22
+ @includes_calls = []
23
+ end
24
+
25
+ def each(&block) = @records.each(&block)
26
+ def count(*) = @records.count
27
+ def offset(*) = self
28
+ def limit(*) = self
29
+
30
+ # Mirrors real ActiveRecord's eventual `ActiveRecord::AssociationNotFoundError`
31
+ # for an unknown association name -- raised here at call time (rather
32
+ # than lazily, as real AR does) so the controller's rescue path around
33
+ # the `.includes` call site is directly exercisable.
34
+ def includes(*associations)
35
+ flat = associations.flatten
36
+ invalid = flat.reject { |a| KNOWN_ASSOCIATIONS.include?(a.respond_to?(:to_sym) ? a.to_sym : a) }
37
+ raise ArgumentError, "Association named #{invalid.first.inspect} was not found" if invalid.any?
38
+
39
+ @includes_calls << flat
40
+ self
41
+ end
42
+ end
43
+
44
+ class Widget
45
+ extend ActiveModel::Naming
46
+
47
+ attr_reader :id, :name
48
+
49
+ def initialize(id:, name:)
50
+ @id = id
51
+ @name = name
52
+ end
53
+
54
+ RELATION = IncludesCapableRelation.new([ new(id: 1, name: "Widget One") ])
55
+
56
+ def self.all = RELATION
57
+ def self.column_names = %w[id name]
58
+ def self.primary_key = "id"
59
+ def self.columns_hash = { "id" => Struct.new(:type).new(:integer) }
60
+ def self.find(id) = all.find { |w| w.to_param == id.to_s }
61
+ def to_param = id.to_s
62
+ def attributes = { "id" => id, "name" => name }
63
+ end
64
+
65
+ class BadWidget
66
+ extend ActiveModel::Naming
67
+
68
+ attr_reader :id, :name
69
+
70
+ def initialize(id:, name:)
71
+ @id = id
72
+ @name = name
73
+ end
74
+
75
+ RELATION = IncludesCapableRelation.new([ new(id: 1, name: "Bad Widget") ])
76
+
77
+ def self.all = RELATION
78
+ def self.column_names = %w[id name]
79
+ def self.primary_key = "id"
80
+ def self.columns_hash = { "id" => Struct.new(:type).new(:integer) }
81
+ def self.find(id) = all.find { |w| w.to_param == id.to_s }
82
+ def to_param = id.to_s
83
+ def attributes = { "id" => id, "name" => name }
84
+ end
85
+
86
+ # Self-contained (this file is runnable standalone via
87
+ # `rake test TEST=test/lib/index_includes_test.rb`, so it can't reach
88
+ # into fixtures defined only in association_linking_test.rb): a scope
89
+ # with no `#includes` method at all, mirroring
90
+ # `ReadOnlyResourceFixtures::Relation`, backing a resource that
91
+ # nonetheless declares `includes :company`. Exercises the "skip
92
+ # silently, don't raise" path.
93
+ class NoIncludesWidget
94
+ extend ActiveModel::Naming
95
+
96
+ attr_reader :id, :name
97
+
98
+ def initialize(id:, name:)
99
+ @id = id
100
+ @name = name
101
+ end
102
+
103
+ def self.all = ReadOnlyResourceFixtures::Relation.new([ new(id: 1, name: "Plain Widget") ])
104
+ def self.column_names = %w[id name]
105
+ def self.primary_key = "id"
106
+ def self.columns_hash = { "id" => Struct.new(:type).new(:integer) }
107
+ def self.find(id) = all.find { |w| w.to_param == id.to_s }
108
+ def to_param = id.to_s
109
+ def attributes = { "id" => id, "name" => name }
110
+ end
111
+ end
112
+
113
+ module Admin
114
+ module Resources
115
+ class IndexIncludesWidgetResource < Admin::Base::Resource
116
+ model IndexIncludesFixtures::Widget
117
+ portal :ops
118
+ section :observability
119
+
120
+ index do
121
+ columns { column :name }
122
+ includes :company, :owner
123
+ end
124
+ end
125
+
126
+ class IndexIncludesBadWidgetResource < Admin::Base::Resource
127
+ model IndexIncludesFixtures::BadWidget
128
+ portal :ops
129
+ section :observability
130
+
131
+ index do
132
+ columns { column :name }
133
+ includes :nonexistent_association
134
+ end
135
+ end
136
+
137
+ class IndexIncludesNoIncludesWidgetResource < Admin::Base::Resource
138
+ model IndexIncludesFixtures::NoIncludesWidget
139
+ portal :ops
140
+ section :observability
141
+
142
+ index do
143
+ columns { column :name }
144
+ includes :company
145
+ end
146
+ end
147
+ end
148
+ end
149
+
150
+ module AdminSuite
151
+ class IndexIncludesTest < ActionDispatch::IntegrationTest
152
+ test "IndexConfig#includes stores the list of associations" do
153
+ config = Admin::Base::Resource::IndexConfig.new
154
+ config.includes(:company, :owner)
155
+ assert_equal [ :company, :owner ], config.includes_list
156
+ end
157
+
158
+ test "IndexConfig#includes defaults to an empty list" do
159
+ assert_equal [], Admin::Base::Resource::IndexConfig.new.includes_list
160
+ end
161
+
162
+ test "IndexConfig#includes drops a nil association instead of storing garbage" do
163
+ config = Admin::Base::Resource::IndexConfig.new
164
+ config.includes(nil)
165
+ assert_equal [], config.includes_list
166
+ end
167
+
168
+ test "IndexConfig#includes tolerates a String association name" do
169
+ config = Admin::Base::Resource::IndexConfig.new
170
+ config.includes("company")
171
+ assert_equal [ "company" ], config.includes_list
172
+ end
173
+
174
+ test "the DSL exposes includes: on a resource's index_config" do
175
+ assert_equal [ :company, :owner ], Admin::Resources::IndexIncludesWidgetResource.index_config.includes_list
176
+ end
177
+
178
+ test "the controller applies includes: to a scope that responds to includes" do
179
+ IndexIncludesFixtures::Widget::RELATION.includes_calls.clear
180
+
181
+ get "/internal/admin_suite/ops/index_includes_widgets"
182
+ assert_response :success
183
+
184
+ assert_equal [ [ :company, :owner ] ], IndexIncludesFixtures::Widget::RELATION.includes_calls
185
+ end
186
+
187
+ test "a scope whose includes raises for a bad association degrades instead of 500ing" do
188
+ logged = []
189
+ Rails.logger.stub(:warn, ->(msg) { logged << msg }) do
190
+ get "/internal/admin_suite/ops/index_includes_bad_widgets"
191
+ end
192
+
193
+ assert_response :success
194
+ assert_includes response.body, "Bad Widget"
195
+ # The rescue in `apply_index_includes` logs the failure -- pinning
196
+ # that here so the "skipped silently" test below (which asserts NO
197
+ # warn fires) is actually distinguishing two different code paths,
198
+ # not just two tests that both happen to pass.
199
+ assert_equal 1, logged.size
200
+ assert_includes logged.first, "nonexistent_association"
201
+ end
202
+
203
+ test "a scope that does not respond to includes is skipped silently, not raised" do
204
+ refute IndexIncludesFixtures::NoIncludesWidget.all.respond_to?(:includes)
205
+
206
+ logged = []
207
+ Rails.logger.stub(:warn, ->(msg) { logged << msg }) do
208
+ get "/internal/admin_suite/ops/index_includes_no_includes_widgets"
209
+ end
210
+
211
+ assert_response :success
212
+ assert_includes response.body, "Plain Widget"
213
+ # `apply_index_includes`'s `respond_to?(:includes)` guard exists so a
214
+ # scope that can never support includes doesn't spam a warning on
215
+ # every request. Deleting that guard would still return a 200 here
216
+ # (the catch-all rescue produces an identical response), so the
217
+ # response alone doesn't prove the guard does anything -- this
218
+ # asserts its actual purpose: no warn fires on this path. Contrast
219
+ # with the bad-association test above, where one does.
220
+ assert_empty logged
221
+ end
222
+ end
223
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+
5
+ module AdminSuite
6
+ class LegacyRendererDeprecationTest < ActionView::TestCase
7
+ include ::ERB::Util
8
+ include AdminSuite::BaseHelper
9
+
10
+ Prompt = Struct.new(:prompt_template)
11
+
12
+ setup { AdminSuite::Renderers::LegacyGleania.reset_deprecation_notices! }
13
+
14
+ test "the legacy prompt renderer still renders its template" do
15
+ html = render_custom_section(Prompt.new("Hello {{name}}"), :prompt_template_preview)
16
+ assert_includes html, "Hello"
17
+ assert_includes html, "name"
18
+ end
19
+
20
+ test "using a legacy renderer logs a deprecation once per key" do
21
+ logged = []
22
+ AdminSuite::Renderers::LegacyGleania.stub(:warn_once_sink, ->(msg) { logged << msg }) do
23
+ 2.times { render_custom_section(Prompt.new("x"), :prompt_template_preview) }
24
+ end
25
+ assert_equal 1, logged.size
26
+ assert_includes logged.first, "deprecated"
27
+ # Deliberately a hardcoded literal, not read off
28
+ # DEPRECATION_MESSAGE_FORMAT: reading the version out of the constant
29
+ # under test and asserting the message contains it is true by
30
+ # construction (format() only substitutes %<key>s, never the version
31
+ # digits), so it can never fail on a wrong retarget -- exactly the
32
+ # property this test exists to catch. If the removal target changes
33
+ # again, this literal must be updated by hand; that's the point.
34
+ assert_includes logged.first, "0.6.0"
35
+ end
36
+
37
+ test "the gem no longer includes the host CustomRenderersHelper constant" do
38
+ source = AdminSuite::Engine.root.join("app/helpers/admin_suite/base_helper.rb").read
39
+ refute_includes source, "Internal::Developer::CustomRenderersHelper"
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,237 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+
5
+ # Reopened (never redefined) so RendererTest can define/remove scratch
6
+ # `Admin::Renderers::<Key>Renderer` probe classes per test, for Finding 2's
7
+ # host-class-vs-gem-default precedence tests below.
8
+ module Admin
9
+ module Renderers
10
+ end
11
+ end
12
+
13
+ module AdminSuite
14
+ class RendererTest < ActionView::TestCase
15
+ # See ShowValueFormatterTest for why this is needed: BaseHelper's
16
+ # primitives (via render_json_block / render_text_block) call bare `h`,
17
+ # which ActionView::TestCase::Behavior does not provide on its own.
18
+ include ::ERB::Util
19
+ include AdminSuite::BaseHelper
20
+
21
+ Record = Struct.new(:id, :payload)
22
+
23
+ class GreetingRenderer < AdminSuite::Renderer
24
+ def render
25
+ content_tag(:p, "Hello #{record.id}")
26
+ end
27
+ end
28
+
29
+ class PrimitivesRenderer < AdminSuite::Renderer
30
+ def render
31
+ safe_join([
32
+ key_value_list([ [ "Status", "active" ] ]),
33
+ data_table([ { name: "a", count: 1 } ], columns: %i[name count]),
34
+ badge("live", color: :green),
35
+ empty_state("nothing here")
36
+ ])
37
+ end
38
+ end
39
+
40
+ setup { AdminSuite::RendererRegistry.register(:greeting, GreetingRenderer) }
41
+ teardown { AdminSuite::RendererRegistry.unregister(:greeting) }
42
+
43
+ test "a renderer subclass receives the record and renders" do
44
+ assert_includes GreetingRenderer.new(Record.new(7), self).render, "Hello 7"
45
+ end
46
+
47
+ test "the base class requires #render" do
48
+ assert_raises(NotImplementedError) { AdminSuite::Renderer.new(Record.new(1), self).render }
49
+ end
50
+
51
+ test "registry lookup returns the registered class and raises for unknown keys" do
52
+ assert_equal GreetingRenderer, AdminSuite::RendererRegistry.lookup(:greeting)
53
+ assert_nil AdminSuite::RendererRegistry.lookup(:nope)
54
+ end
55
+
56
+ test "registered lists every registered key" do
57
+ assert_includes AdminSuite::RendererRegistry.registered, :greeting
58
+ end
59
+
60
+ test "primitives render key-value pairs, tables, badges and empty states" do
61
+ html = PrimitivesRenderer.new(Record.new(1), self).render
62
+ assert_includes html, "Status"
63
+ assert_includes html, "active"
64
+ # data_table humanizes column names for the header (see Renderer#data_table),
65
+ # so ":count" renders as "Count", not "count".
66
+ assert_includes html, "Count"
67
+ assert_includes html, "live"
68
+ assert_includes html, "nothing here"
69
+ end
70
+
71
+ test "data_table renders its empty message when rows are blank" do
72
+ html = AdminSuite::Renderer.new(Record.new(1), self).send(:data_table, [], columns: %i[a], empty: "no rows")
73
+ assert_includes html, "no rows"
74
+ end
75
+
76
+ test "render_custom_section resolves a registered renderer class" do
77
+ assert_includes render_custom_section(Record.new(9), :greeting), "Hello 9"
78
+ end
79
+
80
+ # Lookup-precedence regression guard. Tasks 4 and 5 both insert new
81
+ # branches into render_custom_section (built-ins, then the deprecated
82
+ # Gleania four) between the registry lookup and the "Unknown" fallback —
83
+ # this pins the two boundaries already in place today so neither task can
84
+ # silently invert them.
85
+ test "a legacy custom_renderers proc wins over a registered renderer class" do
86
+ key = :precedence_probe
87
+ AdminSuite::RendererRegistry.register(key, GreetingRenderer)
88
+ saved = AdminSuite.config.custom_renderers[key]
89
+ AdminSuite.config.custom_renderers[key] = ->(record, _view) { "legacy-proc-#{record.id}" }
90
+
91
+ html = render_custom_section(Record.new(5), key)
92
+
93
+ assert_includes html, "legacy-proc-5"
94
+ refute_includes html, "Hello 5"
95
+ ensure
96
+ AdminSuite::RendererRegistry.unregister(key)
97
+ AdminSuite.config.custom_renderers.delete(key)
98
+ AdminSuite.config.custom_renderers[key] = saved if saved
99
+ end
100
+
101
+ # Finding 4 of the whole-branch review: `config.custom_renderers` procs
102
+ # were deprecated on paper only (CHANGELOG, 0.4.0) with no runtime
103
+ # signal -- unlike the four legacy Gleania renderers, which warn once
104
+ # per key. trust_growth has 23 of these procs and needs a way to notice
105
+ # during the migration window.
106
+ test "using a legacy custom_renderers proc logs a deprecation once per key" do
107
+ key = :legacy_proc_warning_probe
108
+ AdminSuite::LegacyCustomRendererProcs.reset_deprecation_notices!
109
+ AdminSuite.config.custom_renderers[key] = ->(record, _view) { "legacy-proc-#{record.id}" }
110
+
111
+ logged = []
112
+ AdminSuite::LegacyCustomRendererProcs.stub(:warn_once_sink, ->(msg) { logged << msg }) do
113
+ 2.times { render_custom_section(Record.new(5), key) }
114
+ end
115
+
116
+ assert_equal 1, logged.size
117
+ assert_includes logged.first, key.to_s
118
+ assert_includes logged.first, "deprecated"
119
+ # Deliberately a hardcoded literal, not read off
120
+ # DEPRECATION_MESSAGE_FORMAT: reading the version out of the constant
121
+ # under test and asserting the message contains it is true by
122
+ # construction (format() only substitutes %<key>s, never the version
123
+ # digits), so it can never fail on a wrong retarget -- exactly the
124
+ # property this test exists to catch. If the removal target changes
125
+ # again, this literal must be updated by hand; that's the point.
126
+ assert_includes logged.first, "0.6.0"
127
+ ensure
128
+ AdminSuite.config.custom_renderers.delete(key)
129
+ end
130
+
131
+ # Finding 2 of the whole-branch review: render_custom_section used to
132
+ # resolve `RendererRegistry.lookup(key) || host_renderer_class(key)`,
133
+ # but the gem's own built-ins and the deprecated Gleania four were
134
+ # registered into that same `lookup` store at require time -- so a host
135
+ # that followed the deprecation advice ("Move it to app/admin/renderers
136
+ # in your app") and defined its own `Admin::Renderers::<Key>Renderer`
137
+ # was silently shadowed, and the deprecation warning never stopped.
138
+ # Built-ins/deprecated-four now live in a separate `register_default`
139
+ # store, checked only *after* a host renderer class.
140
+ test "a host renderer class beats a gem default registered under the same key" do
141
+ key = :host_beats_default_probe
142
+ AdminSuite::RendererRegistry.register_default(key, GreetingRenderer)
143
+ Admin::Renderers.const_set(:HostBeatsDefaultProbeRenderer, Class.new(AdminSuite::Renderer) do
144
+ def render
145
+ "host-class-#{record.id}"
146
+ end
147
+ end)
148
+
149
+ html = render_custom_section(Record.new(7), key)
150
+
151
+ assert_includes html, "host-class-7"
152
+ refute_includes html, "Hello 7"
153
+ ensure
154
+ # `register_default` seeds AdminSuite's *permanent* default store (the
155
+ # same one the built-ins live in), so this probe key must be removed
156
+ # via `unregister_default` -- unlike `unregister`, which only ever
157
+ # touches the explicit store and would silently leave this key behind
158
+ # for every test that runs afterward.
159
+ AdminSuite::RendererRegistry.unregister_default(key)
160
+ if Admin::Renderers.const_defined?(:HostBeatsDefaultProbeRenderer, false)
161
+ Admin::Renderers.send(:remove_const, :HostBeatsDefaultProbeRenderer)
162
+ end
163
+ end
164
+
165
+ test "an explicit registration still beats a host renderer class" do
166
+ key = :explicit_beats_host_probe
167
+ AdminSuite::RendererRegistry.register(key, GreetingRenderer)
168
+ Admin::Renderers.const_set(:ExplicitBeatsHostProbeRenderer, Class.new(AdminSuite::Renderer) do
169
+ def render
170
+ "host-class-#{record.id}"
171
+ end
172
+ end)
173
+
174
+ html = render_custom_section(Record.new(8), key)
175
+
176
+ assert_includes html, "Hello 8"
177
+ refute_includes html, "host-class-8"
178
+ ensure
179
+ AdminSuite::RendererRegistry.unregister(key)
180
+ if Admin::Renderers.const_defined?(:ExplicitBeatsHostProbeRenderer, false)
181
+ Admin::Renderers.send(:remove_const, :ExplicitBeatsHostProbeRenderer)
182
+ end
183
+ end
184
+
185
+ test "a registered renderer class wins over the built-in case branches" do
186
+ # :json_preview has a legacy case branch; a registered class must take it over.
187
+ #
188
+ # Task 4 registers :json_preview as a permanent alias to JsonRenderer,
189
+ # but via `register_default` (the gem-defaults store), not `register`
190
+ # (the explicit store). `lookup` below only ever reads the explicit
191
+ # store, so `previous` is nil here -- there is no explicit registration
192
+ # to save or restore, and the `ensure` branch's plain `unregister` is
193
+ # a safe no-op that cannot touch the permanent default alias (which
194
+ # only `unregister_default` could remove). This test still needs the
195
+ # save/restore shape below in case some future test registers
196
+ # :json_preview explicitly and forgets to clean up after itself.
197
+ previous = AdminSuite::RendererRegistry.lookup(:json_preview)
198
+ AdminSuite::RendererRegistry.register(:json_preview, GreetingRenderer)
199
+ assert_includes render_custom_section(Record.new(6), :json_preview), "Hello 6"
200
+ ensure
201
+ if previous
202
+ AdminSuite::RendererRegistry.register(:json_preview, previous)
203
+ else
204
+ AdminSuite::RendererRegistry.unregister(:json_preview)
205
+ end
206
+ end
207
+
208
+ # json_block/code_block/badge each delegate to a BaseHelper primitive whose
209
+ # real signature differs from the brief's illustrative sketch
210
+ # (render_json_block takes no `title:`; render_label_badge takes `color:`
211
+ # as a keyword, not positional) — these primitives were adapted to match,
212
+ # per the task's explicit instruction. Covered directly since the brief's
213
+ # own PrimitivesRenderer test never exercises json_block or code_block.
214
+ test "json_block renders pretty-printed JSON, with an optional title heading" do
215
+ renderer = AdminSuite::Renderer.new(Record.new(1), self)
216
+ html = renderer.send(:json_block, { a: 1 })
217
+ assert_includes html, "language-json"
218
+ assert_includes html, "&quot;a&quot;"
219
+
220
+ titled = renderer.send(:json_block, { a: 1 }, title: "Payload")
221
+ assert_includes titled, "Payload"
222
+ assert_includes titled, "language-json"
223
+ end
224
+
225
+ test "code_block renders a syntax-highlighted text block" do
226
+ html = AdminSuite::Renderer.new(Record.new(1), self).send(:code_block, "SELECT 1", language: :sql)
227
+ assert_includes html, "SELECT 1"
228
+ assert_includes html, "language-sql"
229
+ end
230
+
231
+ test "badge renders the label with its color class, passed as a keyword" do
232
+ html = AdminSuite::Renderer.new(Record.new(1), self).send(:badge, "live", color: :green)
233
+ assert_includes html, "live"
234
+ assert_includes html, "bg-green-100"
235
+ end
236
+ end
237
+ end