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
|
@@ -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, ""a""
|
|
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
|