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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +210 -1
- data/app/assets/vendor/chart.umd.min.js +14 -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 +138 -6
- data/app/helpers/admin_suite/base_helper.rb +232 -391
- data/app/javascript/admin_suite_application.js +3 -0
- data/app/javascript/controllers/admin_suite/chart_controller.js +173 -0
- data/app/javascript/controllers/admin_suite/markdown_editor_controller.js +21 -2
- data/app/views/admin_suite/panels/_chart.html.erb +158 -18
- data/app/views/admin_suite/panels/_stat.html.erb +10 -0
- data/app/views/admin_suite/resources/index.html.erb +31 -82
- data/app/views/admin_suite/shared/_pagination.html.erb +74 -0
- data/app/views/admin_suite/shared/_sidebar.html.erb +15 -9
- data/app/views/layouts/admin_suite/application.html.erb +11 -3
- data/config/routes.rb +3 -0
- data/lib/admin/base/action_executor.rb +19 -51
- data/lib/admin/base/filter_builder.rb +48 -5
- data/lib/admin/base/resource.rb +92 -17
- data/lib/admin_suite/configuration.rb +32 -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 +81 -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 +232 -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/dashboard_definition.rb +6 -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 +48 -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/association_linking_test.rb +292 -0
- data/test/integration/chart_panel_test.rb +491 -0
- data/test/integration/dashboard_test.rb +9 -2
- data/test/integration/index_table_test.rb +289 -0
- data/test/integration/layout_assets_test.rb +112 -0
- data/test/integration/navigation_sections_test.rb +62 -0
- data/test/integration/pagination_and_stats_test.rb +152 -0
- data/test/integration/searchable_select_search_test.rb +368 -0
- data/test/integration/show_hide_blank_test.rb +195 -0
- data/test/integration/toggle_test.rb +106 -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 +276 -0
- data/test/lib/engine_defaults_test.rb +39 -0
- data/test/lib/form_field_renderer_test.rb +136 -0
- data/test/lib/format_table_cell_test.rb +49 -0
- data/test/lib/index_includes_test.rb +223 -0
- data/test/lib/legacy_renderer_deprecation_test.rb +42 -0
- data/test/lib/renderer_test.rb +237 -0
- data/test/lib/resource_exportable_deprecation_test.rb +47 -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 +121 -5
- metadata +62 -5
|
@@ -4,12 +4,9 @@ require "admin_suite/ui/field_renderer_registry"
|
|
|
4
4
|
|
|
5
5
|
module AdminSuite
|
|
6
6
|
module UI
|
|
7
|
-
#
|
|
8
|
-
# while leaving the legacy implementation available via `super`.
|
|
7
|
+
# Implements `render_form_field` using a registry of field renderers.
|
|
9
8
|
module FormFieldRenderer
|
|
10
9
|
def render_form_field(f, field, resource)
|
|
11
|
-
return super unless defined?(AdminSuite::UI::FieldRendererRegistry)
|
|
12
|
-
|
|
13
10
|
return if field.if_condition.present? && !field.if_condition.call(resource)
|
|
14
11
|
return if field.unless_condition.present? && field.unless_condition.call(resource)
|
|
15
12
|
|
|
@@ -33,9 +30,6 @@ module AdminSuite
|
|
|
33
30
|
field_class: field_class
|
|
34
31
|
)
|
|
35
32
|
|
|
36
|
-
# If the registry doesn't know how to render, fall back to legacy behavior.
|
|
37
|
-
return super if field_html.nil?
|
|
38
|
-
|
|
39
33
|
concat(field_html)
|
|
40
34
|
|
|
41
35
|
concat(content_tag(:p, field.help, class: "mt-1 text-sm text-slate-500")) if field.help.present?
|
|
@@ -102,6 +102,15 @@ AdminSuite::UI::ShowFormatterRegistry.register_class(Float) do |value, view, _re
|
|
|
102
102
|
view.content_tag(:span, view.number_with_delimiter(value), class: "font-mono")
|
|
103
103
|
end
|
|
104
104
|
|
|
105
|
+
AdminSuite::UI::ShowFormatterRegistry.register_class(BigDecimal) do |value, view, _record, _field|
|
|
106
|
+
# `.to_f` avoids number_with_delimiter rendering exponential notation
|
|
107
|
+
# (e.g. "0.98765e4") for BigDecimal input, at the cost of Float precision
|
|
108
|
+
# for values beyond Float's exact range (e.g. sub-cent monetary sums).
|
|
109
|
+
# That's an accepted display-only tradeoff here — don't "fix" this by
|
|
110
|
+
# dropping the conversion, or exponential notation comes back.
|
|
111
|
+
view.content_tag(:span, view.number_with_delimiter(value.to_f), class: "font-mono")
|
|
112
|
+
end
|
|
113
|
+
|
|
105
114
|
AdminSuite::UI::ShowFormatterRegistry.register_default do |value, view, _record, field_name|
|
|
106
115
|
value_str = value.to_s
|
|
107
116
|
|
|
@@ -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
|
@@ -8,17 +8,65 @@ end
|
|
|
8
8
|
|
|
9
9
|
require "pagy"
|
|
10
10
|
|
|
11
|
+
# Hard dependency, declared in the gemspec but not a direct Gemfile entry in
|
|
12
|
+
# any host -- Bundler's `Bundler.require` only auto-requires gems declared
|
|
13
|
+
# directly in the Gemfile (or via `gemspec`'s own path entry), not a gem's
|
|
14
|
+
# *transitive* runtime dependencies. Same reasoning as `pagy` above and
|
|
15
|
+
# `redcarpet`/`rouge` in markdown_renderer.rb: required explicitly here so
|
|
16
|
+
# `turbo_frame_tag`/`turbo_stream`/the `:turbo_stream` MIME type are real
|
|
17
|
+
# regardless of what else the host's own Gemfile happens to require.
|
|
18
|
+
require "turbo-rails"
|
|
19
|
+
|
|
11
20
|
require "admin_suite/version"
|
|
21
|
+
require "admin_suite/deprecation"
|
|
12
22
|
require "admin_suite/configuration"
|
|
13
23
|
require "admin_suite/markdown_renderer"
|
|
14
24
|
require "admin_suite/theme_palette"
|
|
15
25
|
require "admin_suite/portal_registry"
|
|
26
|
+
require "admin_suite/section_definition"
|
|
16
27
|
require "admin_suite/portal_definition"
|
|
17
28
|
require "admin_suite/auth"
|
|
18
29
|
require "admin_suite/ui/form_field_renderer"
|
|
19
30
|
require "admin_suite/ui/show_value_formatter"
|
|
31
|
+
require "admin_suite/renderer"
|
|
32
|
+
require "admin_suite/renderer_registry"
|
|
33
|
+
require "admin_suite/renderers/json_renderer"
|
|
34
|
+
require "admin_suite/renderers/key_value_renderer"
|
|
35
|
+
require "admin_suite/renderers/table_from_renderer"
|
|
36
|
+
require "admin_suite/renderers/code_renderer"
|
|
37
|
+
require "admin_suite/renderers/legacy_gleania"
|
|
38
|
+
require "admin_suite/legacy_custom_renderer_procs"
|
|
39
|
+
require "admin_suite/definition_loader"
|
|
40
|
+
require "admin_suite/host_autoload_policy"
|
|
20
41
|
require "admin_suite/engine"
|
|
21
42
|
|
|
43
|
+
# Registered as *defaults* (register_default), not explicit registrations:
|
|
44
|
+
# `render_custom_section` checks a host's own `RendererRegistry.register`
|
|
45
|
+
# call and a host's `Admin::Renderers::<Key>Renderer` class *before* falling
|
|
46
|
+
# back to these, so a host following the deprecation advice below (or simply
|
|
47
|
+
# overriding a built-in like `:json`) actually takes effect instead of being
|
|
48
|
+
# silently shadowed by the gem's own boot-time registrations.
|
|
49
|
+
AdminSuite::RendererRegistry.register_default(:json, AdminSuite::Renderers::JsonRenderer)
|
|
50
|
+
AdminSuite::RendererRegistry.register_default(:key_value, AdminSuite::Renderers::KeyValueRenderer)
|
|
51
|
+
AdminSuite::RendererRegistry.register_default(:table_from, AdminSuite::Renderers::TableFromRenderer)
|
|
52
|
+
AdminSuite::RendererRegistry.register_default(:code, AdminSuite::Renderers::CodeRenderer)
|
|
53
|
+
|
|
54
|
+
# Aliases: `:json_preview`/`:code_preview` were the previous generic
|
|
55
|
+
# `render_custom_section` case branches (BaseHelper#render_json_preview /
|
|
56
|
+
# #render_code_preview, now deleted). Host resources declared against those
|
|
57
|
+
# keys keep working unchanged against the new built-in renderers.
|
|
58
|
+
AdminSuite::RendererRegistry.register_default(:json_preview, AdminSuite::Renderers::JsonRenderer)
|
|
59
|
+
AdminSuite::RendererRegistry.register_default(:code_preview, AdminSuite::Renderers::CodeRenderer)
|
|
60
|
+
|
|
61
|
+
# Deprecated (removal targeted at 0.6.0, moved from the originally-planned
|
|
62
|
+
# 0.5.0 -- both host migrations, gleania and trust_growth, are in flight):
|
|
63
|
+
# the four Gleania-specific LLM chat-transcript renderers. See
|
|
64
|
+
# `AdminSuite::Renderers::LegacyGleania` for details.
|
|
65
|
+
AdminSuite::RendererRegistry.register_default(:prompt_template_preview, AdminSuite::Renderers::LegacyGleania::PromptTemplateRenderer)
|
|
66
|
+
AdminSuite::RendererRegistry.register_default(:messages_preview, AdminSuite::Renderers::LegacyGleania::MessagesPreviewRenderer)
|
|
67
|
+
AdminSuite::RendererRegistry.register_default(:tool_args_preview, AdminSuite::Renderers::LegacyGleania::ToolArgsRenderer)
|
|
68
|
+
AdminSuite::RendererRegistry.register_default(:turn_messages_preview, AdminSuite::Renderers::LegacyGleania::TurnMessagesRenderer)
|
|
69
|
+
|
|
22
70
|
module AdminSuite
|
|
23
71
|
class << self
|
|
24
72
|
# @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,292 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "test_helper"
|
|
4
|
+
|
|
5
|
+
# `LinkingFixtures::Company` (an AR-backed fixture: it genuinely subclasses
|
|
6
|
+
# the `ActiveRecord::Base` stub defined in test_helper.rb) and its
|
|
7
|
+
# registered `Admin::Resources::LinkingCompanyResource` both live in
|
|
8
|
+
# test_helper.rb, not here -- established there because Tasks 4 and 7 reuse
|
|
9
|
+
# them, and test_helper.rb is required by every test file regardless of
|
|
10
|
+
# which single file `rake test TEST=...` runs. Everything below is specific
|
|
11
|
+
# to this task's link-rendering assertions.
|
|
12
|
+
module LinkingFixtures
|
|
13
|
+
# Same shape as Company, but deliberately never registered as an
|
|
14
|
+
# Admin::Resources::* class -- exercises the "no resource registered"
|
|
15
|
+
# degrade path in `auto_admin_suite_path_for`.
|
|
16
|
+
class UnregisteredVendor < ActiveRecord::Base
|
|
17
|
+
extend ActiveModel::Naming
|
|
18
|
+
|
|
19
|
+
attr_reader :id, :name
|
|
20
|
+
|
|
21
|
+
def initialize(id: 3, name: "Ghost Vendor")
|
|
22
|
+
@id = id
|
|
23
|
+
@name = name
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def self.column_names = %w[id name]
|
|
27
|
+
def self.primary_key = "id"
|
|
28
|
+
def self.columns_hash = { "id" => Struct.new(:type).new(:integer) }
|
|
29
|
+
def self.find(_id) = new
|
|
30
|
+
def to_param = id.to_s
|
|
31
|
+
def attributes = { "id" => id, "name" => name }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# `item_display_title` calls `item.name` unguarded when `respond_to?(:name)`
|
|
35
|
+
# is true. This record's `#name` raises, simulating a host model whose
|
|
36
|
+
# display method blows up on bad data -- the point is that a single bad
|
|
37
|
+
# row must degrade, not 500 the entire page.
|
|
38
|
+
class RaisingCo < ActiveRecord::Base
|
|
39
|
+
extend ActiveModel::Naming
|
|
40
|
+
|
|
41
|
+
attr_reader :id
|
|
42
|
+
|
|
43
|
+
def initialize(id: 9)
|
|
44
|
+
@id = id
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def name
|
|
48
|
+
raise "boom: no display name for you"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def self.column_names = %w[id name]
|
|
52
|
+
def self.primary_key = "id"
|
|
53
|
+
def self.columns_hash = { "id" => Struct.new(:type).new(:integer) }
|
|
54
|
+
def self.find(_id) = new
|
|
55
|
+
def to_param = id.to_s
|
|
56
|
+
def attributes = { "id" => id }
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Simulates an unsaved/unpersisted AR record: real `ActiveRecord::Base#to_param`
|
|
60
|
+
# returns nil when `persisted?` is false. `auto_admin_suite_path_for`'s
|
|
61
|
+
# `resource_path(..., id: nil)` call must degrade to no-link, not 500.
|
|
62
|
+
class UnpersistedVendor < ActiveRecord::Base
|
|
63
|
+
extend ActiveModel::Naming
|
|
64
|
+
|
|
65
|
+
attr_reader :id, :name
|
|
66
|
+
|
|
67
|
+
def initialize
|
|
68
|
+
@id = nil
|
|
69
|
+
@name = "Draft Vendor"
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def self.column_names = %w[id name]
|
|
73
|
+
def self.primary_key = "id"
|
|
74
|
+
def self.columns_hash = { "id" => Struct.new(:type).new(:integer) }
|
|
75
|
+
def self.find(_id) = new
|
|
76
|
+
def to_param = nil
|
|
77
|
+
def attributes = { "id" => id, "name" => name }
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# PORO host resource (mirrors the RedirectFixtures/ReadOnlyResourceFixtures
|
|
81
|
+
# convention). Its index column returns each of the AR values above, and
|
|
82
|
+
# its show page renders an association table where one column is itself an
|
|
83
|
+
# AR value -- the two call sites Task 3 unifies (`render_column_value`'s
|
|
84
|
+
# fallback branch and `format_table_cell`'s AR branch).
|
|
85
|
+
class Deal
|
|
86
|
+
extend ActiveModel::Naming
|
|
87
|
+
|
|
88
|
+
attr_reader :id, :label, :company
|
|
89
|
+
|
|
90
|
+
def initialize(id:, label:, company:)
|
|
91
|
+
@id = id
|
|
92
|
+
@label = label
|
|
93
|
+
@company = company
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def self.all = ReadOnlyResourceFixtures::Relation.new([
|
|
97
|
+
new(id: 1, label: "Q3 renewal", company: Company.new),
|
|
98
|
+
new(id: 2, label: "Raises on display", company: RaisingCo.new),
|
|
99
|
+
new(id: 3, label: "Unpersisted vendor", company: UnpersistedVendor.new),
|
|
100
|
+
new(id: 4, label: "No resource registered", company: UnregisteredVendor.new)
|
|
101
|
+
])
|
|
102
|
+
def self.column_names = %w[id label]
|
|
103
|
+
def self.primary_key = "id"
|
|
104
|
+
def self.columns_hash = { "id" => Struct.new(:type).new(:integer) }
|
|
105
|
+
def self.find(id) = all.find { |d| d.to_param == id.to_s }
|
|
106
|
+
def to_param = id.to_s
|
|
107
|
+
def attributes = { "id" => id, "label" => label }
|
|
108
|
+
|
|
109
|
+
def line_items
|
|
110
|
+
[
|
|
111
|
+
LineItem.new(1, "Renewal", Company.new),
|
|
112
|
+
LineItem.new(2, "Ghost line", UnregisteredVendor.new)
|
|
113
|
+
]
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
LineItem = Struct.new(:id, :label, :company)
|
|
118
|
+
|
|
119
|
+
# Five deals, each pointing at a *distinct instance* of the *same*
|
|
120
|
+
# `LinkingFixtures::Company` class. `auto_admin_suite_path_for`'s registry
|
|
121
|
+
# lookup is keyed on `item.class`, not identity, so a correct memo
|
|
122
|
+
# resolves the resource once for this whole page -- pre-memo, each of
|
|
123
|
+
# the 5 rows independently re-ran the full registry scan.
|
|
124
|
+
class RepeatedCompanyDeal
|
|
125
|
+
extend ActiveModel::Naming
|
|
126
|
+
|
|
127
|
+
attr_reader :id, :label, :company
|
|
128
|
+
|
|
129
|
+
def initialize(id:, label:, company:)
|
|
130
|
+
@id = id
|
|
131
|
+
@label = label
|
|
132
|
+
@company = company
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
ROWS = Array.new(5) { |i| new(id: i + 1, label: "Deal #{i + 1}", company: Company.new(id: 100 + i, name: "Company #{i + 1}")) }
|
|
136
|
+
|
|
137
|
+
def self.all = ReadOnlyResourceFixtures::Relation.new(ROWS)
|
|
138
|
+
def self.column_names = %w[id label]
|
|
139
|
+
def self.primary_key = "id"
|
|
140
|
+
def self.columns_hash = { "id" => Struct.new(:type).new(:integer) }
|
|
141
|
+
def self.find(id) = all.find { |d| d.to_param == id.to_s }
|
|
142
|
+
def to_param = id.to_s
|
|
143
|
+
def attributes = { "id" => id, "label" => label }
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
module Admin
|
|
148
|
+
module Resources
|
|
149
|
+
class LinkingRepeatedCompanyDealResource < Admin::Base::Resource
|
|
150
|
+
model LinkingFixtures::RepeatedCompanyDeal
|
|
151
|
+
portal :ops
|
|
152
|
+
section :observability
|
|
153
|
+
|
|
154
|
+
index do
|
|
155
|
+
columns do
|
|
156
|
+
column :label
|
|
157
|
+
column :company
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
class LinkingRaisingCoResource < Admin::Base::Resource
|
|
163
|
+
model LinkingFixtures::RaisingCo
|
|
164
|
+
portal :ops
|
|
165
|
+
section :observability
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
class LinkingUnpersistedVendorResource < Admin::Base::Resource
|
|
169
|
+
model LinkingFixtures::UnpersistedVendor
|
|
170
|
+
portal :ops
|
|
171
|
+
section :observability
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
class LinkingDealResource < Admin::Base::Resource
|
|
175
|
+
model LinkingFixtures::Deal
|
|
176
|
+
portal :ops
|
|
177
|
+
section :observability
|
|
178
|
+
|
|
179
|
+
index do
|
|
180
|
+
columns do
|
|
181
|
+
column :label
|
|
182
|
+
column :company
|
|
183
|
+
end
|
|
184
|
+
# `Deal.all` is a `ReadOnlyResourceFixtures::Relation` -- it has no
|
|
185
|
+
# `#includes` method at all. This exercises Task 4's "skip
|
|
186
|
+
# silently, don't raise" path on every request this test file
|
|
187
|
+
# already makes to `/linking_deals`, not just a dedicated test.
|
|
188
|
+
includes :company
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
show do
|
|
192
|
+
section :line_items, association: :line_items, display: :table, columns: [ :label, :company ]
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
module AdminSuite
|
|
199
|
+
class AssociationLinkingTest < ActionDispatch::IntegrationTest
|
|
200
|
+
# ERB auto-escapes `<%= %>` output, so the literal bug string
|
|
201
|
+
# (`#<Company:0x...>`) never appears as raw `#<` in `response.body` --
|
|
202
|
+
# it shows up HTML-entity-escaped as `#<LinkingFixtures::Company:0x...>`.
|
|
203
|
+
# A `refute_includes response.body, "#<"` check would therefore pass
|
|
204
|
+
# vacuously against the *un*fixed code. Assert on the class-name leak
|
|
205
|
+
# itself instead, which survives escaping either way.
|
|
206
|
+
AR_INSPECT_LEAK = "LinkingFixtures::"
|
|
207
|
+
|
|
208
|
+
test "an index column returning an AR value links to that record's admin page" do
|
|
209
|
+
get "/internal/admin_suite/ops/linking_deals"
|
|
210
|
+
assert_response :success
|
|
211
|
+
|
|
212
|
+
refute_includes response.body, AR_INSPECT_LEAK
|
|
213
|
+
assert_includes response.body, "Acme Corp"
|
|
214
|
+
assert_match %r{<a[^>]+href="[^"]*linking_companies/7"[^>]*>\s*Acme Corp\s*</a>}, response.body
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
test "an AR value with a raising display title degrades instead of 500ing the page" do
|
|
218
|
+
get "/internal/admin_suite/ops/linking_deals"
|
|
219
|
+
assert_response :success
|
|
220
|
+
refute_includes response.body, AR_INSPECT_LEAK
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
test "an unpersisted AR record (to_param -> nil) renders plain text, not a broken link" do
|
|
224
|
+
get "/internal/admin_suite/ops/linking_deals"
|
|
225
|
+
assert_response :success
|
|
226
|
+
assert_includes response.body, "Draft Vendor"
|
|
227
|
+
refute_match %r{<a[^>]*>\s*Draft Vendor\s*</a>}, response.body
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
test "an AR value with no registered resource renders plain text, not a broken link" do
|
|
231
|
+
get "/internal/admin_suite/ops/linking_deals"
|
|
232
|
+
assert_response :success
|
|
233
|
+
assert_includes response.body, "Ghost Vendor"
|
|
234
|
+
refute_match %r{<a[^>]*>\s*Ghost Vendor\s*</a>}, response.body
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
test "an association table cell that is an AR value renders as a link too" do
|
|
238
|
+
get "/internal/admin_suite/ops/linking_deals/1"
|
|
239
|
+
assert_response :success
|
|
240
|
+
|
|
241
|
+
refute_includes response.body, AR_INSPECT_LEAK
|
|
242
|
+
assert_match %r{<a[^>]+href="[^"]*linking_companies/7"[^>]*>\s*Acme Corp\s*</a>}, response.body
|
|
243
|
+
# The second line item's company has no registered resource: plain text.
|
|
244
|
+
assert_includes response.body, "Ghost Vendor"
|
|
245
|
+
refute_match %r{<a[^>]*>\s*Ghost Vendor\s*</a>}, response.body
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
# Non-vacuous by construction: it counts actual invocations of
|
|
249
|
+
# `ensure_admin_resources_loaded_for!` -- called exactly once per
|
|
250
|
+
# registry *cache miss* inside `admin_suite_resource_for`, and nowhere
|
|
251
|
+
# else in the gem. (A page-wide spy on
|
|
252
|
+
# `Admin::Base::Resource.registered_resources` itself was tried first
|
|
253
|
+
# and rejected: navigation building calls it many more times per
|
|
254
|
+
# request for unrelated reasons -- e.g. `resources_for_portal` -- so
|
|
255
|
+
# that count is dominated by noise this task didn't touch.)
|
|
256
|
+
#
|
|
257
|
+
# Pre-memo, `auto_admin_suite_path_for` called
|
|
258
|
+
# `ensure_admin_resources_loaded_for!(item.class)` unconditionally on
|
|
259
|
+
# every invocation -- 5 rows of the same class meant 5 calls (and 2
|
|
260
|
+
# full registry scans apiece: the `.any?` inside it, plus the
|
|
261
|
+
# `registered_resources.find` right after). Memoized, only the first
|
|
262
|
+
# row's lookup is a cache miss; the other 4 hit
|
|
263
|
+
# `@admin_suite_resource_for` and never call this method at all -- so
|
|
264
|
+
# the call count for 5 rows must be 1, not 5. A test that only
|
|
265
|
+
# asserted "the memo hash is non-empty" would still pass if the memo
|
|
266
|
+
# were consulted but the scan ran unconditionally anyway; this asserts
|
|
267
|
+
# the scan was actually skipped.
|
|
268
|
+
test "auto_admin_suite_path_for memoizes the registry lookup per class, not per row" do
|
|
269
|
+
call_count = 0
|
|
270
|
+
helper_module = AdminSuite::BaseHelper
|
|
271
|
+
original_method = helper_module.instance_method(:ensure_admin_resources_loaded_for!)
|
|
272
|
+
|
|
273
|
+
helper_module.send(:define_method, :ensure_admin_resources_loaded_for!) do |model_class|
|
|
274
|
+
call_count += 1
|
|
275
|
+
original_method.bind(self).call(model_class)
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
begin
|
|
279
|
+
get "/internal/admin_suite/ops/linking_repeated_company_deals"
|
|
280
|
+
assert_response :success
|
|
281
|
+
assert_includes response.body, "Company 1"
|
|
282
|
+
assert_includes response.body, "Company 5"
|
|
283
|
+
ensure
|
|
284
|
+
helper_module.send(:define_method, :ensure_admin_resources_loaded_for!, original_method)
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
assert_equal 1, call_count,
|
|
288
|
+
"expected exactly one registry lookup for 5 rows of the same class (LinkingFixtures::Company) " \
|
|
289
|
+
"-- got #{call_count}, which means the memo isn't preventing a rescan per row"
|
|
290
|
+
end
|
|
291
|
+
end
|
|
292
|
+
end
|