admin_suite 0.3.1 → 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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +82 -0
- data/app/assets/vendor/easymde.min.css +7 -0
- data/app/assets/vendor/easymde.min.js +7 -0
- data/app/controllers/admin_suite/application_controller.rb +26 -47
- data/app/controllers/admin_suite/portals_controller.rb +2 -2
- data/app/controllers/admin_suite/resources_controller.rb +8 -1
- data/app/helpers/admin_suite/base_helper.rb +49 -385
- data/app/javascript/controllers/admin_suite/markdown_editor_controller.js +21 -2
- data/app/views/admin_suite/panels/_stat.html.erb +10 -0
- data/app/views/admin_suite/resources/index.html.erb +7 -77
- data/app/views/admin_suite/shared/_pagination.html.erb +74 -0
- data/app/views/admin_suite/shared/_sidebar.html.erb +1 -1
- data/app/views/layouts/admin_suite/application.html.erb +5 -3
- data/lib/admin/base/action_executor.rb +19 -51
- data/lib/admin/base/resource.rb +53 -14
- data/lib/admin_suite/configuration.rb +28 -3
- data/lib/admin_suite/definition_loader.rb +194 -0
- data/lib/admin_suite/deprecation.rb +48 -0
- data/lib/admin_suite/engine.rb +55 -76
- data/lib/admin_suite/host_autoload_policy.rb +132 -0
- data/lib/admin_suite/legacy_custom_renderer_procs.rb +29 -0
- data/lib/admin_suite/portal_definition.rb +11 -0
- data/lib/admin_suite/renderer.rb +133 -0
- data/lib/admin_suite/renderer_registry.rb +70 -0
- data/lib/admin_suite/renderers/code_renderer.rb +15 -0
- data/lib/admin_suite/renderers/json_renderer.rb +15 -0
- data/lib/admin_suite/renderers/key_value_renderer.rb +39 -0
- data/lib/admin_suite/renderers/legacy_gleania.rb +230 -0
- data/lib/admin_suite/renderers/table_from_renderer.rb +22 -0
- data/lib/admin_suite/section_definition.rb +42 -0
- data/lib/admin_suite/ui/field_renderer_registry.rb +31 -4
- data/lib/admin_suite/ui/form_field_renderer.rb +1 -7
- data/lib/admin_suite/ui/show_formatter_registry.rb +9 -0
- data/lib/admin_suite/ui/show_value_formatter.rb +7 -3
- data/lib/admin_suite/version.rb +1 -1
- data/lib/admin_suite.rb +37 -0
- data/lib/generators/admin_suite/install/templates/admin_suite.rb +0 -4
- data/test/controllers/resources_controller_test.rb +76 -1
- data/test/integration/layout_assets_test.rb +112 -0
- data/test/integration/navigation_sections_test.rb +41 -0
- data/test/integration/pagination_and_stats_test.rb +152 -0
- data/test/lib/action_executor_redirect_test.rb +41 -0
- data/test/lib/builtin_renderers_test.rb +202 -0
- data/test/lib/definition_loader_test.rb +264 -0
- data/test/lib/engine_defaults_test.rb +39 -0
- data/test/lib/form_field_renderer_test.rb +64 -0
- data/test/lib/legacy_renderer_deprecation_test.rb +35 -0
- data/test/lib/renderer_test.rb +221 -0
- data/test/lib/resource_exportable_deprecation_test.rb +39 -0
- data/test/lib/show_value_formatter_test.rb +88 -0
- data/test/lib/zeitwerk_integration_test.rb +28 -64
- data/test/test_helper.rb +77 -0
- 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
|
-
#
|
|
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
|
-
|
|
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
|
data/lib/admin_suite/version.rb
CHANGED
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
|
|
@@ -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
|