admin_suite 0.4.0 → 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 +134 -7
- data/app/assets/vendor/chart.umd.min.js +14 -0
- data/app/controllers/admin_suite/resources_controller.rb +130 -5
- data/app/helpers/admin_suite/base_helper.rb +187 -10
- data/app/javascript/admin_suite_application.js +3 -0
- data/app/javascript/controllers/admin_suite/chart_controller.js +173 -0
- data/app/views/admin_suite/panels/_chart.html.erb +158 -18
- data/app/views/admin_suite/resources/index.html.erb +24 -5
- data/app/views/admin_suite/shared/_sidebar.html.erb +14 -8
- data/app/views/layouts/admin_suite/application.html.erb +6 -0
- data/config/routes.rb +3 -0
- data/lib/admin/base/filter_builder.rb +48 -5
- data/lib/admin/base/resource.rb +43 -7
- data/lib/admin_suite/configuration.rb +10 -6
- data/lib/admin_suite/engine.rb +1 -1
- data/lib/admin_suite/legacy_custom_renderer_procs.rb +1 -1
- data/lib/admin_suite/renderer_registry.rb +11 -0
- data/lib/admin_suite/renderers/legacy_gleania.rb +7 -5
- data/lib/admin_suite/ui/dashboard_definition.rb +6 -0
- data/lib/admin_suite/version.rb +1 -1
- data/lib/admin_suite.rb +13 -2
- 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/navigation_sections_test.rb +21 -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/definition_loader_test.rb +12 -0
- data/test/lib/form_field_renderer_test.rb +83 -11
- 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 +8 -1
- data/test/lib/renderer_test.rb +23 -7
- data/test/lib/resource_exportable_deprecation_test.rb +10 -2
- data/test/test_helper.rb +47 -8
- metadata +35 -5
data/lib/admin_suite.rb
CHANGED
|
@@ -8,6 +8,15 @@ 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"
|
|
12
21
|
require "admin_suite/deprecation"
|
|
13
22
|
require "admin_suite/configuration"
|
|
@@ -49,8 +58,10 @@ AdminSuite::RendererRegistry.register_default(:code, AdminSuite::Renderers::Code
|
|
|
49
58
|
AdminSuite::RendererRegistry.register_default(:json_preview, AdminSuite::Renderers::JsonRenderer)
|
|
50
59
|
AdminSuite::RendererRegistry.register_default(:code_preview, AdminSuite::Renderers::CodeRenderer)
|
|
51
60
|
|
|
52
|
-
# Deprecated (
|
|
53
|
-
#
|
|
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.
|
|
54
65
|
AdminSuite::RendererRegistry.register_default(:prompt_template_preview, AdminSuite::Renderers::LegacyGleania::PromptTemplateRenderer)
|
|
55
66
|
AdminSuite::RendererRegistry.register_default(:messages_preview, AdminSuite::Renderers::LegacyGleania::MessagesPreviewRenderer)
|
|
56
67
|
AdminSuite::RendererRegistry.register_default(:tool_args_preview, AdminSuite::Renderers::LegacyGleania::ToolArgsRenderer)
|
|
@@ -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
|