admin_suite 0.3.0 → 0.4.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 (56) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +88 -0
  3. data/app/assets/vendor/easymde.min.css +7 -0
  4. data/app/assets/vendor/easymde.min.js +7 -0
  5. data/app/controllers/admin_suite/application_controller.rb +26 -47
  6. data/app/controllers/admin_suite/portals_controller.rb +2 -2
  7. data/app/controllers/admin_suite/resources_controller.rb +8 -1
  8. data/app/helpers/admin_suite/base_helper.rb +49 -385
  9. data/app/javascript/controllers/admin_suite/markdown_editor_controller.js +21 -2
  10. data/app/views/admin_suite/panels/_chart.html.erb +3 -2
  11. data/app/views/admin_suite/panels/_stat.html.erb +10 -0
  12. data/app/views/admin_suite/resources/index.html.erb +7 -77
  13. data/app/views/admin_suite/shared/_pagination.html.erb +74 -0
  14. data/app/views/admin_suite/shared/_sidebar.html.erb +1 -1
  15. data/app/views/layouts/admin_suite/application.html.erb +5 -3
  16. data/lib/admin/base/action_executor.rb +19 -51
  17. data/lib/admin/base/resource.rb +53 -14
  18. data/lib/admin_suite/configuration.rb +28 -3
  19. data/lib/admin_suite/definition_loader.rb +194 -0
  20. data/lib/admin_suite/deprecation.rb +48 -0
  21. data/lib/admin_suite/engine.rb +55 -76
  22. data/lib/admin_suite/host_autoload_policy.rb +132 -0
  23. data/lib/admin_suite/legacy_custom_renderer_procs.rb +29 -0
  24. data/lib/admin_suite/portal_definition.rb +11 -0
  25. data/lib/admin_suite/renderer.rb +133 -0
  26. data/lib/admin_suite/renderer_registry.rb +70 -0
  27. data/lib/admin_suite/renderers/code_renderer.rb +15 -0
  28. data/lib/admin_suite/renderers/json_renderer.rb +15 -0
  29. data/lib/admin_suite/renderers/key_value_renderer.rb +39 -0
  30. data/lib/admin_suite/renderers/legacy_gleania.rb +230 -0
  31. data/lib/admin_suite/renderers/table_from_renderer.rb +22 -0
  32. data/lib/admin_suite/section_definition.rb +42 -0
  33. data/lib/admin_suite/ui/field_renderer_registry.rb +31 -4
  34. data/lib/admin_suite/ui/form_field_renderer.rb +1 -7
  35. data/lib/admin_suite/ui/show_formatter_registry.rb +9 -0
  36. data/lib/admin_suite/ui/show_value_formatter.rb +7 -3
  37. data/lib/admin_suite/version.rb +1 -1
  38. data/lib/admin_suite.rb +37 -0
  39. data/lib/generators/admin_suite/install/templates/admin_suite.rb +0 -4
  40. data/test/controllers/resources_controller_test.rb +76 -1
  41. data/test/integration/dashboard_test.rb +40 -0
  42. data/test/integration/layout_assets_test.rb +112 -0
  43. data/test/integration/navigation_sections_test.rb +41 -0
  44. data/test/integration/pagination_and_stats_test.rb +152 -0
  45. data/test/lib/action_executor_redirect_test.rb +41 -0
  46. data/test/lib/builtin_renderers_test.rb +202 -0
  47. data/test/lib/definition_loader_test.rb +264 -0
  48. data/test/lib/engine_defaults_test.rb +39 -0
  49. data/test/lib/form_field_renderer_test.rb +64 -0
  50. data/test/lib/legacy_renderer_deprecation_test.rb +35 -0
  51. data/test/lib/renderer_test.rb +221 -0
  52. data/test/lib/resource_exportable_deprecation_test.rb +39 -0
  53. data/test/lib/show_value_formatter_test.rb +88 -0
  54. data/test/lib/zeitwerk_integration_test.rb +28 -64
  55. data/test/test_helper.rb +77 -0
  56. metadata +28 -1
@@ -4,8 +4,7 @@ require "admin_suite/ui/show_formatter_registry"
4
4
 
5
5
  module AdminSuite
6
6
  module UI
7
- # Overrides `format_show_value` to use a registry of show value formatters,
8
- # while leaving the legacy implementation available via `super`.
7
+ # Implements `format_show_value` using a registry of show value formatters.
9
8
  module ShowValueFormatter
10
9
  def format_show_value(record, field_name)
11
10
  value = record.public_send(field_name) rescue nil
@@ -70,7 +69,12 @@ module AdminSuite
70
69
 
71
70
  return formatted unless formatted.nil?
72
71
 
73
- super
72
+ # Unreachable in practice: the registry's unconditional default
73
+ # handler always returns a value for any input, so `formatted` is
74
+ # never nil here. This is a last-resort guard, not a real code path
75
+ # — kept only in case a future handler is registered that returns
76
+ # nil, so rendering degrades to a plain span instead of raising.
77
+ content_tag(:span, value.to_s, class: "text-slate-900")
74
78
  end
75
79
 
76
80
  private
@@ -2,7 +2,7 @@
2
2
 
3
3
  module AdminSuite
4
4
  module Version
5
- VERSION = "0.3.0"
5
+ VERSION = "0.4.0"
6
6
  end
7
7
 
8
8
  # Backward-compatible constant.
data/lib/admin_suite.rb CHANGED
@@ -9,16 +9,53 @@ end
9
9
  require "pagy"
10
10
 
11
11
  require "admin_suite/version"
12
+ require "admin_suite/deprecation"
12
13
  require "admin_suite/configuration"
13
14
  require "admin_suite/markdown_renderer"
14
15
  require "admin_suite/theme_palette"
15
16
  require "admin_suite/portal_registry"
17
+ require "admin_suite/section_definition"
16
18
  require "admin_suite/portal_definition"
17
19
  require "admin_suite/auth"
18
20
  require "admin_suite/ui/form_field_renderer"
19
21
  require "admin_suite/ui/show_value_formatter"
22
+ require "admin_suite/renderer"
23
+ require "admin_suite/renderer_registry"
24
+ require "admin_suite/renderers/json_renderer"
25
+ require "admin_suite/renderers/key_value_renderer"
26
+ require "admin_suite/renderers/table_from_renderer"
27
+ require "admin_suite/renderers/code_renderer"
28
+ require "admin_suite/renderers/legacy_gleania"
29
+ require "admin_suite/legacy_custom_renderer_procs"
30
+ require "admin_suite/definition_loader"
31
+ require "admin_suite/host_autoload_policy"
20
32
  require "admin_suite/engine"
21
33
 
34
+ # Registered as *defaults* (register_default), not explicit registrations:
35
+ # `render_custom_section` checks a host's own `RendererRegistry.register`
36
+ # call and a host's `Admin::Renderers::<Key>Renderer` class *before* falling
37
+ # back to these, so a host following the deprecation advice below (or simply
38
+ # overriding a built-in like `:json`) actually takes effect instead of being
39
+ # silently shadowed by the gem's own boot-time registrations.
40
+ AdminSuite::RendererRegistry.register_default(:json, AdminSuite::Renderers::JsonRenderer)
41
+ AdminSuite::RendererRegistry.register_default(:key_value, AdminSuite::Renderers::KeyValueRenderer)
42
+ AdminSuite::RendererRegistry.register_default(:table_from, AdminSuite::Renderers::TableFromRenderer)
43
+ AdminSuite::RendererRegistry.register_default(:code, AdminSuite::Renderers::CodeRenderer)
44
+
45
+ # Aliases: `:json_preview`/`:code_preview` were the previous generic
46
+ # `render_custom_section` case branches (BaseHelper#render_json_preview /
47
+ # #render_code_preview, now deleted). Host resources declared against those
48
+ # keys keep working unchanged against the new built-in renderers.
49
+ AdminSuite::RendererRegistry.register_default(:json_preview, AdminSuite::Renderers::JsonRenderer)
50
+ AdminSuite::RendererRegistry.register_default(:code_preview, AdminSuite::Renderers::CodeRenderer)
51
+
52
+ # Deprecated (removed in 0.5.0): the four Gleania-specific LLM chat-transcript
53
+ # renderers. See `AdminSuite::Renderers::LegacyGleania` for details.
54
+ AdminSuite::RendererRegistry.register_default(:prompt_template_preview, AdminSuite::Renderers::LegacyGleania::PromptTemplateRenderer)
55
+ AdminSuite::RendererRegistry.register_default(:messages_preview, AdminSuite::Renderers::LegacyGleania::MessagesPreviewRenderer)
56
+ AdminSuite::RendererRegistry.register_default(:tool_args_preview, AdminSuite::Renderers::LegacyGleania::ToolArgsRenderer)
57
+ AdminSuite::RendererRegistry.register_default(:turn_messages_preview, AdminSuite::Renderers::LegacyGleania::TurnMessagesRenderer)
58
+
22
59
  module AdminSuite
23
60
  class << self
24
61
  # @return [AdminSuite::Configuration]
@@ -74,10 +74,6 @@ AdminSuite.configure do |config|
74
74
  # In apps that use Tailwind, this is typically `:app`.
75
75
  config.host_stylesheet = :app
76
76
 
77
- # Tailwind CDN fallback (helps when host doesn't compile Tailwind).
78
- # Disable if you provide your own Tailwind build.
79
- config.tailwind_cdn = true
80
-
81
77
  # Optional docs link shown in the sidebar.
82
78
  # config.docs_url = "https://..."
83
79
  config.docs_url = nil
@@ -5,7 +5,7 @@ require "test_helper"
5
5
  module AdminSuite
6
6
  class ResourcesControllerTest < ActiveSupport::TestCase
7
7
  class TestController < ResourcesController
8
- attr_writer :test_resource_config
8
+ attr_writer :test_resource_config, :test_params
9
9
  attr_reader :filter_calls, :paginated_scope
10
10
 
11
11
  def initialize
@@ -13,6 +13,10 @@ module AdminSuite
13
13
  @filter_calls = 0
14
14
  end
15
15
 
16
+ def params
17
+ @test_params ||= {}
18
+ end
19
+
16
20
  private
17
21
 
18
22
  def resource_config
@@ -76,5 +80,76 @@ module AdminSuite
76
80
  assert_equal 37, controller.instance_variable_get(:@stats).second[:value]
77
81
  assert_equal :paginated, controller.instance_variable_get(:@collection)
78
82
  end
83
+
84
+ # Finding 3(a) of the whole-branch review: `set_resource`'s bare
85
+ # `rescue ActiveRecord::RecordNotFound` is the masking variant. In a host
86
+ # without ActiveRecord loaded, resolving that constant while dispatching
87
+ # an exception raises `NameError`, destroying whatever the real error
88
+ # was (here, a PORO model that doesn't implement `column_names`).
89
+ class PoroWithoutColumns
90
+ def self.column_names
91
+ raise NoMethodError, "undefined method 'column_names' for #{name}"
92
+ end
93
+ end
94
+
95
+ class PoroResourceConfig < Admin::Base::Resource
96
+ model PoroWithoutColumns
97
+ end
98
+
99
+ # A model shape that implements just enough of the ActiveRecord surface
100
+ # `set_resource`/`find_friendly_resource!` touch to exercise the genuine
101
+ # `ActiveRecord::RecordNotFound` recovery path end-to-end.
102
+ class SlugLookupModel
103
+ Column = Struct.new(:type)
104
+ Record = Struct.new(:id, :slug)
105
+
106
+ def self.column_names
107
+ %w[id slug]
108
+ end
109
+
110
+ def self.primary_key
111
+ "id"
112
+ end
113
+
114
+ def self.columns_hash
115
+ { "id" => Column.new(:string) }
116
+ end
117
+
118
+ def self.find(_id)
119
+ raise ActiveRecord::RecordNotFound, "not found by primary key"
120
+ end
121
+
122
+ def self.find_by(slug:)
123
+ slug == "the-slug" ? Record.new(42, "the-slug") : nil
124
+ end
125
+ end
126
+
127
+ class SlugResourceConfig < Admin::Base::Resource
128
+ model SlugLookupModel
129
+ end
130
+
131
+ test "set_resource still recovers via find_friendly_resource! on a genuine ActiveRecord::RecordNotFound" do
132
+ controller = TestController.new
133
+ controller.test_resource_config = SlugResourceConfig
134
+ controller.test_params = { id: "the-slug" }
135
+
136
+ controller.send(:set_resource)
137
+
138
+ assert_equal 42, controller.send(:resource).id
139
+ end
140
+
141
+ test "set_resource propagates the original error instead of masking it with NameError" do
142
+ controller = TestController.new
143
+ controller.test_resource_config = PoroResourceConfig
144
+ controller.test_params = { id: "1" }
145
+
146
+ original = ActiveRecord::RecordNotFound
147
+ ActiveRecord.send(:remove_const, :RecordNotFound)
148
+
149
+ error = assert_raises(NoMethodError) { controller.send(:set_resource) }
150
+ assert_match(/column_names/, error.message)
151
+ ensure
152
+ ActiveRecord.const_set(:RecordNotFound, original) unless defined?(ActiveRecord::RecordNotFound)
153
+ end
79
154
  end
80
155
  end
@@ -65,5 +65,45 @@ module AdminSuite
65
65
  AdminSuite.config.root_dashboard_description = old_description
66
66
  AdminSuite.reset_root_dashboard!
67
67
  end
68
+
69
+ test "chart bars have a definite-height wrapper and preserve full labels" do
70
+ old_globs = AdminSuite.config.dashboard_globs
71
+
72
+ Dir.mktmpdir("admin-suite-chart") do |dir|
73
+ dashboard_rb = File.join(dir, "dashboard.rb")
74
+
75
+ File.write(dashboard_rb, <<~'RUBY')
76
+ # frozen_string_literal: true
77
+
78
+ AdminSuite.root_dashboard do
79
+ row do
80
+ chart_panel "Daily Cost", data: -> {
81
+ [
82
+ { label: "Jul 30", value: 0.01 },
83
+ { label: "Thursday", value: 10 }
84
+ ]
85
+ }
86
+ end
87
+ end
88
+ RUBY
89
+
90
+ AdminSuite.reset_root_dashboard!
91
+ AdminSuite.config.dashboard_globs = [ File.join(dir, "*.rb") ]
92
+
93
+ get "/internal/admin_suite"
94
+ assert_response :success
95
+
96
+ document = Nokogiri::HTML(response.body)
97
+ chart = document.at_xpath("//h3[normalize-space()='Daily Cost']/ancestor::div[contains(@class, 'rounded-xl')]")
98
+ assert chart
99
+ assert_equal 2, chart.css(".h-16 > .h-full").size
100
+ assert_equal ["height: 2%", "height: 100%"], chart.css(".h-16 > .h-full > div").map { |bar| bar["style"] }
101
+ assert_includes chart.text, "Jul 30"
102
+ assert_includes chart.text, "Thursday"
103
+ end
104
+ ensure
105
+ AdminSuite.config.dashboard_globs = old_globs
106
+ AdminSuite.reset_root_dashboard!
107
+ end
68
108
  end
69
109
  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,41 @@
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
+ end
41
+ 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
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+
5
+ module AdminSuite
6
+ class ActionExecutorRedirectTest < ActiveSupport::TestCase
7
+ Action = Struct.new(:name, :label, keyword_init: true)
8
+
9
+ test "AASM is not referenced as a bare constant" do
10
+ source = File.read(File.expand_path("../../lib/admin/base/action_executor.rb", __dir__))
11
+ refute_match(/rescue AASM::InvalidTransition/, source)
12
+ end
13
+
14
+ test "redirect target is derived for any action returning a persisted record" do
15
+ executor = Admin::Base::ActionExecutor.new(RedirectFixtures::WidgetResource, :clone_widget, nil)
16
+ url = executor.send(:redirect_url_for_action, Action.new(name: :clone_widget, label: "Clone"), RedirectFixtures::Widget.new)
17
+ assert_equal "/internal/admin_suite/ops/redirect_widgets/42", url
18
+ end
19
+
20
+ test "no redirect for results that are not persisted records" do
21
+ executor = Admin::Base::ActionExecutor.new(RedirectFixtures::WidgetResource, :ping, nil)
22
+ assert_nil executor.send(:redirect_url_for_action, Action.new(name: :ping, label: "Ping"), true)
23
+ end
24
+
25
+ test "a plain RuntimeError from a model action surfaces its real message, not a NameError" do
26
+ executor = Admin::Base::ActionExecutor.new(Admin::Resources::ExceptionHandlingBoomerResource, :kaboom, nil)
27
+ result = executor.execute_member(ExceptionHandlingFixtures::Boomer.new)
28
+
29
+ assert result.failure?
30
+ assert_equal "Error: actual failure message", result.message
31
+ end
32
+
33
+ test "an exception whose class name contains InvalidTransition is reported as a friendly failure" do
34
+ executor = Admin::Base::ActionExecutor.new(Admin::Resources::ExceptionHandlingStateMachineResource, :transition, nil)
35
+ result = executor.execute_member(ExceptionHandlingFixtures::StateMachineWidget.new)
36
+
37
+ assert result.failure?
38
+ assert_equal "Invalid state transition: cannot transition from draft to published", result.message
39
+ end
40
+ end
41
+ end