admin_suite 0.4.0 → 0.6.1
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/.gitignore +7 -0
- data/CHANGELOG.md +203 -18
- data/CONTRIBUTING.md +9 -4
- data/README.md +21 -5
- data/app/assets/vendor/chart.umd.min.js +14 -0
- data/app/controllers/admin_suite/application_controller.rb +12 -11
- data/app/controllers/admin_suite/mcp_controller.rb +36 -0
- data/app/controllers/admin_suite/resources_controller.rb +71 -17
- 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 +25 -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 +8 -0
- data/lib/admin/base/filter_builder.rb +48 -5
- data/lib/admin/base/resource.rb +46 -32
- data/lib/admin_suite/auth/host_user.rb +42 -0
- data/lib/admin_suite/auth/strategy.rb +1 -1
- data/lib/admin_suite/auth.rb +15 -0
- data/lib/admin_suite/authorization_context.rb +28 -0
- data/lib/admin_suite/configuration.rb +66 -8
- data/lib/admin_suite/engine.rb +1 -1
- data/lib/admin_suite/legacy_custom_renderer_procs.rb +1 -1
- data/lib/admin_suite/mcp/authorization.rb +68 -0
- data/lib/admin_suite/mcp/serializer.rb +138 -0
- data/lib/admin_suite/mcp/tools/aggregate.rb +55 -0
- data/lib/admin_suite/mcp/tools/describe_resources.rb +63 -0
- data/lib/admin_suite/mcp/tools/get_record.rb +41 -0
- data/lib/admin_suite/mcp/tools/list_records.rb +67 -0
- data/lib/admin_suite/mcp.rb +53 -0
- data/lib/admin_suite/query.rb +71 -0
- data/lib/admin_suite/renderer_registry.rb +11 -0
- data/lib/admin_suite/ui/dashboard_definition.rb +6 -0
- data/lib/admin_suite/ui/show_value_formatter.rb +2 -2
- data/lib/admin_suite/version.rb +1 -1
- data/lib/admin_suite.rb +23 -7
- data/lib/generators/admin_suite/install/templates/admin_suite.rb +5 -2
- data/test/controllers/resources_controller_test.rb +19 -13
- data/test/integration/association_linking_test.rb +292 -0
- data/test/integration/authentication_test.rb +10 -0
- data/test/integration/authorization_test.rb +20 -3
- data/test/integration/chart_panel_test.rb +491 -0
- data/test/integration/dashboard_test.rb +9 -2
- data/test/integration/index_query_characterization_test.rb +229 -0
- data/test/integration/index_table_test.rb +289 -0
- data/test/integration/mcp_aggregate_test.rb +44 -0
- data/test/integration/mcp_authorization_test.rb +102 -0
- data/test/integration/mcp_endpoint_test.rb +89 -0
- data/test/integration/mcp_get_record_test.rb +62 -0
- data/test/integration/mcp_instrumentation_test.rb +107 -0
- data/test/integration/mcp_list_records_test.rb +75 -0
- data/test/integration/mcp_parity_test.rb +155 -0
- data/test/integration/mcp_release_test.rb +131 -0
- data/test/integration/navigation_sections_test.rb +21 -0
- data/test/integration/read_only_resource_test.rb +128 -2
- data/test/integration/searchable_select_search_test.rb +369 -0
- data/test/integration/show_hide_blank_test.rb +195 -0
- data/test/integration/toggle_test.rb +106 -0
- data/test/lib/auth_host_user_test.rb +52 -0
- data/test/lib/auth_http_basic_test.rb +10 -0
- data/test/lib/auth_test.rb +20 -3
- data/test/lib/authorization_context_test.rb +127 -0
- data/test/lib/definition_loader_test.rb +12 -0
- data/test/lib/engine_defaults_test.rb +1 -2
- 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/mcp_serializer_test.rb +107 -0
- data/test/lib/query_test.rb +91 -0
- data/test/lib/removed_deprecations_test.rb +16 -0
- data/test/lib/renderer_test.rb +23 -7
- data/test/publish_workflow_test.rb +63 -0
- data/test/test_helper.rb +73 -8
- metadata +76 -9
- data/lib/admin_suite/renderers/legacy_gleania.rb +0 -230
- data/test/lib/legacy_renderer_deprecation_test.rb +0 -35
- data/test/lib/resource_exportable_deprecation_test.rb +0 -39
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AdminSuite
|
|
4
|
+
class McpController < ApplicationController
|
|
5
|
+
before_action :validate_mcp_origin!, prepend: true
|
|
6
|
+
# Cross-origin browsers cannot send application/json without a CORS
|
|
7
|
+
# preflight; form-compatible content types still require a CSRF token.
|
|
8
|
+
protect_from_forgery with: :exception, unless: :mcp_json_request?
|
|
9
|
+
|
|
10
|
+
def create
|
|
11
|
+
return head :not_found unless AdminSuite.config.mcp.enabled
|
|
12
|
+
return head :unauthorized unless admin_suite_actor
|
|
13
|
+
return head :method_not_allowed unless request.post?
|
|
14
|
+
|
|
15
|
+
AdminSuite::DefinitionLoader.load!(:resources)
|
|
16
|
+
server = AdminSuite::Mcp.server_for(actor: admin_suite_actor, request: request)
|
|
17
|
+
transport = ::MCP::Server::Transports::StreamableHTTPTransport.new(
|
|
18
|
+
server, stateless: true, allowed_hosts: [request.host]
|
|
19
|
+
)
|
|
20
|
+
status, headers, body = transport.handle_request(request)
|
|
21
|
+
self.status = status
|
|
22
|
+
headers.each { |name, value| response.set_header(name, value) }
|
|
23
|
+
self.response_body = body
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def validate_mcp_origin!
|
|
29
|
+
head :forbidden if request.origin && request.origin != request.base_url
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def mcp_json_request?
|
|
33
|
+
request.media_type == "application/json"
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -6,23 +6,52 @@ module AdminSuite
|
|
|
6
6
|
include Pagy::Frontend
|
|
7
7
|
|
|
8
8
|
before_action :require_resource_config!
|
|
9
|
-
before_action :enforce_read_only!, only: %i[new create edit update destroy toggle]
|
|
10
|
-
|
|
9
|
+
before_action :enforce_read_only!, only: %i[new create edit update destroy toggle execute_action bulk_action]
|
|
10
|
+
# `search` is excluded even though it can receive an `:id`-shaped query
|
|
11
|
+
# param: unlike show/edit/update/destroy, a record has no business
|
|
12
|
+
# participating in this action at all. Without this exclusion, a stray
|
|
13
|
+
# `?id=` would (a) hand an attacker-chosen record to `config.authorize`'s
|
|
14
|
+
# `record:` on an action that should never carry one, and (b) let a
|
|
15
|
+
# denied actor distinguish 404 (bad id) from 403 (denied) -- an
|
|
16
|
+
# existence oracle over `find_friendly_resource!`'s slug/uuid/token
|
|
17
|
+
# lookups, on a resource they have no read access to.
|
|
18
|
+
before_action :set_resource, if: -> { params[:id].present? && !%w[index new create search].include?(action_name) }
|
|
11
19
|
before_action :authorize_admin_suite!
|
|
12
20
|
|
|
13
21
|
helper_method :resource_config, :resource_class, :resource, :collection, :current_portal, :resource_name
|
|
14
22
|
|
|
15
23
|
# GET /:portal/:resource_name
|
|
16
24
|
def index
|
|
17
|
-
|
|
25
|
+
query = AdminSuite::Query.new(resource_config: resource_config, params: params)
|
|
26
|
+
scope = query.scope
|
|
18
27
|
@stats = calculate_stats(scope) if resource_config&.index_config&.stats_list&.any?
|
|
19
|
-
@pagy, @collection =
|
|
28
|
+
@pagy, @collection = pagy(scope, limit: query.per_page)
|
|
20
29
|
end
|
|
21
30
|
|
|
22
31
|
# GET /:portal/:resource_name/:id
|
|
23
32
|
def show
|
|
24
33
|
end
|
|
25
34
|
|
|
35
|
+
# GET /:portal/:resource_name/search?q=term
|
|
36
|
+
#
|
|
37
|
+
# Feeds `searchable_select_controller.js`'s `fetchOptions`, which expects
|
|
38
|
+
# a bare JSON array (not `{results: [...]}`) of objects each carrying
|
|
39
|
+
# `id`/`value` and `name`/`title`/`label`. Matches that contract exactly
|
|
40
|
+
# -- the JS needs no changes.
|
|
41
|
+
#
|
|
42
|
+
# Reuses `FilterBuilder.search_predicate`, the same ILIKE-over-
|
|
43
|
+
# `searchable_fields` logic the index's own search box uses, so this can
|
|
44
|
+
# only ever search the resource's declared `searchable` whitelist --
|
|
45
|
+
# never an arbitrary column supplied via `q`. `require_resource_config!`
|
|
46
|
+
# and `authorize_admin_suite!` (both already-registered before_actions,
|
|
47
|
+
# the latter driven by `AUTHORIZATION_VERBS["search"] = :read` below)
|
|
48
|
+
# gate this exactly like every other action on this controller: unknown
|
|
49
|
+
# resource names 404 before either runs, and a denying `config.authorize`
|
|
50
|
+
# 403s before any query executes.
|
|
51
|
+
def search
|
|
52
|
+
render json: search_results.first(SEARCH_RESULT_LIMIT).map { |record| search_result_json(record) }
|
|
53
|
+
end
|
|
54
|
+
|
|
26
55
|
# GET /:portal/:resource_name/new
|
|
27
56
|
def new
|
|
28
57
|
@resource = resource_class.new
|
|
@@ -129,15 +158,49 @@ module AdminSuite
|
|
|
129
158
|
|
|
130
159
|
private
|
|
131
160
|
|
|
161
|
+
# Hard cap on searchable_select results, regardless of table size or how
|
|
162
|
+
# permissive the resource's `searchable` list is.
|
|
163
|
+
SEARCH_RESULT_LIMIT = 25
|
|
164
|
+
|
|
132
165
|
# Controller action -> authorization verb.
|
|
133
166
|
AUTHORIZATION_VERBS = {
|
|
134
|
-
"index" => :read, "show" => :read,
|
|
167
|
+
"index" => :read, "show" => :read, "search" => :read,
|
|
135
168
|
"new" => :create, "create" => :create,
|
|
136
169
|
"edit" => :update, "update" => :update, "toggle" => :update,
|
|
137
170
|
"destroy" => :destroy,
|
|
138
171
|
"execute_action" => :execute, "bulk_action" => :execute
|
|
139
172
|
}.freeze
|
|
140
173
|
|
|
174
|
+
# Filters the resource's own records by `params[:q]` via the shared
|
|
175
|
+
# `FilterBuilder` predicate. A nil predicate (blank `q`, no index config,
|
|
176
|
+
# no declared `searchable` fields, or `q` shorter than
|
|
177
|
+
# `FilterBuilder::MIN_SEARCH_LENGTH`) means no results at all here --
|
|
178
|
+
# deliberately *not* the "fall back to unfiltered scope" behavior
|
|
179
|
+
# `FilterBuilder#apply_search` uses for the index filter box. A raw JSON
|
|
180
|
+
# endpoint must never hand back arbitrary rows just because the query
|
|
181
|
+
# was empty or the resource isn't configured for search.
|
|
182
|
+
#
|
|
183
|
+
# @return [Enumerable]
|
|
184
|
+
def search_results
|
|
185
|
+
predicate = Admin::Base::FilterBuilder.search_predicate(resource_config&.index_config, params[:q])
|
|
186
|
+
return [] unless predicate
|
|
187
|
+
|
|
188
|
+
conditions, search_term = predicate
|
|
189
|
+
resource_class.where(conditions, search: search_term)
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# @param record [Object]
|
|
193
|
+
# @return [Hash]
|
|
194
|
+
def search_result_json(record)
|
|
195
|
+
{ id: record.id, name: search_result_label(record) }
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def search_result_label(record)
|
|
199
|
+
return record.name if record.respond_to?(:name) && record.name.present?
|
|
200
|
+
return record.title if record.respond_to?(:title) && record.title.present?
|
|
201
|
+
record.to_s
|
|
202
|
+
end
|
|
203
|
+
|
|
141
204
|
# Enforces the host's `config.authorize` hook. Nil hook = allowed
|
|
142
205
|
# (authentication remains the gate). Falsy return = 403.
|
|
143
206
|
#
|
|
@@ -151,7 +214,7 @@ module AdminSuite
|
|
|
151
214
|
action: AUTHORIZATION_VERBS.fetch(action_name),
|
|
152
215
|
resource: resource_config,
|
|
153
216
|
record: (defined?(@resource) ? @resource : nil),
|
|
154
|
-
controller: self
|
|
217
|
+
context: AdminSuite::AuthorizationContext.new(surface: :web, controller: self, request: request)
|
|
155
218
|
)
|
|
156
219
|
head :forbidden unless permitted
|
|
157
220
|
end
|
|
@@ -219,17 +282,6 @@ module AdminSuite
|
|
|
219
282
|
@collection
|
|
220
283
|
end
|
|
221
284
|
|
|
222
|
-
def filtered_collection
|
|
223
|
-
return resource_class.all unless resource_config&.index_config
|
|
224
|
-
|
|
225
|
-
Admin::Base::FilterBuilder.new(resource_config, params).apply(resource_class.all)
|
|
226
|
-
end
|
|
227
|
-
|
|
228
|
-
def paginate_collection(scope)
|
|
229
|
-
per_page = resource_config&.index_config&.per_page || 25
|
|
230
|
-
pagy(scope, items: per_page)
|
|
231
|
-
end
|
|
232
|
-
|
|
233
285
|
def calculate_stats(scope)
|
|
234
286
|
resource_config.index_config.stats_list.map do |stat_def|
|
|
235
287
|
value =
|
|
@@ -247,6 +299,8 @@ module AdminSuite
|
|
|
247
299
|
end
|
|
248
300
|
|
|
249
301
|
def enforce_read_only!
|
|
302
|
+
# Declared member/bulk actions are mutations; read_only rejects them
|
|
303
|
+
# the same way as CRUD/toggle rather than advertising a write path.
|
|
250
304
|
head :not_found if resource_config&.read_only?
|
|
251
305
|
end
|
|
252
306
|
|
|
@@ -141,7 +141,43 @@ module AdminSuite
|
|
|
141
141
|
elsif column.content.is_a?(Proc)
|
|
142
142
|
column.content.call(record)
|
|
143
143
|
else
|
|
144
|
-
record.public_send(column.name) rescue "—"
|
|
144
|
+
value = record.public_send(column.name) rescue "—"
|
|
145
|
+
if active_record_base?(value)
|
|
146
|
+
render_association_value(value)
|
|
147
|
+
else
|
|
148
|
+
# `rescue "—"` above only fires on an exception; a genuinely nil
|
|
149
|
+
# attribute reaches here untouched and used to render as an empty
|
|
150
|
+
# cell. Every other surface in the gem (`format_table_cell`,
|
|
151
|
+
# `render_association_value`'s own rescues) already shows "—" for
|
|
152
|
+
# nil, so this closes the one place that didn't -- including a
|
|
153
|
+
# nil `belongs_to` value, which Task 3 deliberately left alone
|
|
154
|
+
# because this task owns it.
|
|
155
|
+
value.nil? ? "—" : value
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Maps a column's `align:` DSL option to a literal Tailwind class.
|
|
161
|
+
#
|
|
162
|
+
# `align` is resource-author-supplied, not end-user input, but it's
|
|
163
|
+
# still an arbitrary value handed to us from outside this method, so
|
|
164
|
+
# this follows the same precedent as the stat panel's `color` mapping
|
|
165
|
+
# (`app/views/admin_suite/panels/_stat.html.erb`) and the dashboard
|
|
166
|
+
# row's `span` clamp (`panels_helper.rb#render_panel`): a closed
|
|
167
|
+
# `case`/`else` over known values, never `"text-#{align}"` string
|
|
168
|
+
# interpolation. Dynamic Tailwind class names are invisible to the
|
|
169
|
+
# content scanner (unstyled in production) even when they happen to be
|
|
170
|
+
# spelled correctly, and an unrecognized or malformed `align:` here
|
|
171
|
+
# must degrade to no alignment class rather than emit a broken one.
|
|
172
|
+
#
|
|
173
|
+
# @param align [Symbol, String, nil]
|
|
174
|
+
# @return [String]
|
|
175
|
+
def column_align_class(align)
|
|
176
|
+
case align.respond_to?(:to_sym) ? align.to_sym : align
|
|
177
|
+
when :right then "text-right"
|
|
178
|
+
when :center then "text-center"
|
|
179
|
+
when :left then "text-left"
|
|
180
|
+
else ""
|
|
145
181
|
end
|
|
146
182
|
end
|
|
147
183
|
|
|
@@ -310,9 +346,7 @@ module AdminSuite
|
|
|
310
346
|
def auto_admin_suite_path_for(item)
|
|
311
347
|
return nil unless active_record_base?(item)
|
|
312
348
|
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
resource = Admin::Base::Resource.registered_resources.find { |r| r.model_class == item.class }
|
|
349
|
+
resource = admin_suite_resource_for(item.class)
|
|
316
350
|
return nil unless resource&.portal_name && resource.respond_to?(:resource_name_plural)
|
|
317
351
|
|
|
318
352
|
resource_path(portal: resource.portal_name, resource_name: resource.resource_name_plural, id: item.to_param)
|
|
@@ -320,6 +354,25 @@ module AdminSuite
|
|
|
320
354
|
nil
|
|
321
355
|
end
|
|
322
356
|
|
|
357
|
+
# Memoized per-request (the helper is mixed into a view instance created
|
|
358
|
+
# fresh per request) resource-class lookup. Before this, every rendered
|
|
359
|
+
# association value re-ran `ensure_admin_resources_loaded_for!`'s
|
|
360
|
+
# `registered_resources.any?` scan *plus* a `registered_resources.find`
|
|
361
|
+
# scan -- both full linear scans of the registry (28-38 resources in the
|
|
362
|
+
# real hosts) -- once per rendered row. Keying the memo on the model
|
|
363
|
+
# class (not the item's identity) means a page with N rows of the same
|
|
364
|
+
# class costs one lookup, not N.
|
|
365
|
+
#
|
|
366
|
+
# @param model_class [Class]
|
|
367
|
+
# @return [Class, nil] the registered `Admin::Base::Resource` subclass, if any
|
|
368
|
+
def admin_suite_resource_for(model_class)
|
|
369
|
+
@admin_suite_resource_for ||= {}
|
|
370
|
+
return @admin_suite_resource_for[model_class] if @admin_suite_resource_for.key?(model_class)
|
|
371
|
+
|
|
372
|
+
ensure_admin_resources_loaded_for!(model_class)
|
|
373
|
+
@admin_suite_resource_for[model_class] = Admin::Base::Resource.registered_resources.find { |r| r.model_class == model_class }
|
|
374
|
+
end
|
|
375
|
+
|
|
323
376
|
def ensure_admin_resources_loaded_for!(model_class)
|
|
324
377
|
already_loaded = Admin::Base::Resource.registered_resources.any? { |r| r.model_class == model_class }
|
|
325
378
|
return if already_loaded
|
|
@@ -365,7 +418,11 @@ module AdminSuite
|
|
|
365
418
|
elsif section.association.present?
|
|
366
419
|
render_association_section(resource, section)
|
|
367
420
|
elsif section.fields.any?
|
|
368
|
-
position == :sidebar
|
|
421
|
+
if position == :sidebar
|
|
422
|
+
render_sidebar_fields(resource, section.fields, hide_blank: section.hide_blank)
|
|
423
|
+
else
|
|
424
|
+
render_main_fields(resource, section.fields, hide_blank: section.hide_blank)
|
|
425
|
+
end
|
|
369
426
|
else
|
|
370
427
|
content_tag(:p, "No content", class: "text-slate-400 italic text-sm")
|
|
371
428
|
end
|
|
@@ -373,11 +430,13 @@ module AdminSuite
|
|
|
373
430
|
end
|
|
374
431
|
end
|
|
375
432
|
|
|
376
|
-
def render_sidebar_fields(resource, fields)
|
|
433
|
+
def render_sidebar_fields(resource, fields, hide_blank: false)
|
|
377
434
|
content_tag(:div, class: "space-y-3") do
|
|
378
435
|
fields.each do |field_name|
|
|
379
436
|
value = resource.public_send(field_name) rescue nil
|
|
380
|
-
if
|
|
437
|
+
next if hide_blank && show_value_blank?(value)
|
|
438
|
+
|
|
439
|
+
if attached_file_value?(value)
|
|
381
440
|
concat(render_sidebar_attachment(value))
|
|
382
441
|
else
|
|
383
442
|
concat(content_tag(:div, class: "flex justify-between items-start gap-2") do
|
|
@@ -425,9 +484,18 @@ module AdminSuite
|
|
|
425
484
|
end
|
|
426
485
|
end
|
|
427
486
|
|
|
428
|
-
def render_main_fields(resource, fields)
|
|
487
|
+
def render_main_fields(resource, fields, hide_blank: false)
|
|
429
488
|
content_tag(:dl, class: "space-y-6") do
|
|
430
489
|
fields.each do |field_name|
|
|
490
|
+
# Only re-read the value (and only when `hide_blank` is actually
|
|
491
|
+
# on) so a plain `field :foo` row with no `hide_blank:` costs
|
|
492
|
+
# exactly what it cost before this option existed --
|
|
493
|
+
# `format_show_value` re-reads the value itself below regardless.
|
|
494
|
+
if hide_blank
|
|
495
|
+
value = resource.public_send(field_name) rescue nil
|
|
496
|
+
next if show_value_blank?(value)
|
|
497
|
+
end
|
|
498
|
+
|
|
431
499
|
concat(content_tag(:div) do
|
|
432
500
|
concat(content_tag(:dt, field_name.to_s.humanize, class: "text-sm font-medium text-slate-500 mb-2"))
|
|
433
501
|
concat(content_tag(:dd, class: "text-sm text-slate-900") { format_show_value(resource, field_name) })
|
|
@@ -436,6 +504,38 @@ module AdminSuite
|
|
|
436
504
|
end
|
|
437
505
|
end
|
|
438
506
|
|
|
507
|
+
# Whether `resource.public_send(field_name)` should be omitted entirely
|
|
508
|
+
# from a `hide_blank: true` show panel.
|
|
509
|
+
#
|
|
510
|
+
# Deliberately not `value.blank?`: `false.blank?` is `true` in Ruby, and
|
|
511
|
+
# `false` renders as a real, meaningful grey "No" toggle icon today (see
|
|
512
|
+
# `ShowFormatterRegistry`'s `FalseClass` handler) -- hiding it would
|
|
513
|
+
# erase a real signal, not absence of one. Same reasoning for `0` and
|
|
514
|
+
# `0.0`: neither responds to `:empty?`, so both are kept. A
|
|
515
|
+
# whitespace-only string (`" "`) is also kept -- `String#empty?`
|
|
516
|
+
# checks length, not content, and this option only claims to hide
|
|
517
|
+
# values with *zero* content, not "content a human would consider
|
|
518
|
+
# meaningless."
|
|
519
|
+
#
|
|
520
|
+
# @param value [Object]
|
|
521
|
+
# @return [Boolean]
|
|
522
|
+
def show_value_blank?(value)
|
|
523
|
+
value.nil? || (value.respond_to?(:empty?) && value.empty?)
|
|
524
|
+
end
|
|
525
|
+
private :show_value_blank?
|
|
526
|
+
|
|
527
|
+
# True for an `ActiveStorage::Attached::One`/`::Many` proxy -- guarded
|
|
528
|
+
# with `defined?` because the host (and this gem's own dummy test app)
|
|
529
|
+
# may not load ActiveStorage at all.
|
|
530
|
+
#
|
|
531
|
+
# @param value [Object]
|
|
532
|
+
# @return [Boolean]
|
|
533
|
+
def attached_file_value?(value)
|
|
534
|
+
(defined?(ActiveStorage::Attached::One) && value.is_a?(ActiveStorage::Attached::One)) ||
|
|
535
|
+
(defined?(ActiveStorage::Attached::Many) && value.is_a?(ActiveStorage::Attached::Many))
|
|
536
|
+
end
|
|
537
|
+
private :attached_file_value?
|
|
538
|
+
|
|
439
539
|
# ---- association rendering ----
|
|
440
540
|
def render_association_section(resource, section)
|
|
441
541
|
associated = resource.public_send(section.association) rescue nil
|
|
@@ -611,17 +711,56 @@ module AdminSuite
|
|
|
611
711
|
when true, false then value ? "Yes" : "No"
|
|
612
712
|
when Time, DateTime then value.strftime("%b %d, %H:%M")
|
|
613
713
|
when Date then value.strftime("%b %d, %Y")
|
|
714
|
+
# Aligns with `format_show_value`'s Integer/Float/BigDecimal handling
|
|
715
|
+
# (see `AdminSuite::UI::ShowFormatterRegistry`) -- table cells used to
|
|
716
|
+
# render large numbers with no delimiter at all (via the `else`
|
|
717
|
+
# branch's plain `to_s`), inconsistent with the same value shown on
|
|
718
|
+
# the record's own show page.
|
|
719
|
+
when Integer, Float then number_with_delimiter(value)
|
|
720
|
+
# `.to_f` avoids number_with_delimiter rendering exponential notation
|
|
721
|
+
# for BigDecimal input, matching the show-value formatter's tradeoff.
|
|
722
|
+
when BigDecimal then number_with_delimiter(value.to_f)
|
|
614
723
|
else
|
|
615
724
|
# A `when ActiveRecord::Base` clause would evaluate that constant
|
|
616
725
|
# reference unconditionally (crashing in a host without ActiveRecord
|
|
617
726
|
# loaded), so this branch is checked explicitly via the predicate
|
|
618
727
|
# instead of folded into the `case`.
|
|
619
|
-
return
|
|
728
|
+
return render_association_value(value) if active_record_base?(value)
|
|
620
729
|
|
|
621
730
|
value.to_s.truncate(50)
|
|
622
731
|
end
|
|
623
732
|
end
|
|
624
733
|
|
|
734
|
+
# Renders a `belongs_to`-shaped association value (typically reached via
|
|
735
|
+
# `render_column_value`'s fallback branch or `format_table_cell`'s AR
|
|
736
|
+
# branch) as its display title, wrapped in a link to the record's own
|
|
737
|
+
# admin page when one resolves -- never the bare `#<Company:0x...>` that
|
|
738
|
+
# ERB's implicit `to_s` would otherwise produce for an AR object, and
|
|
739
|
+
# never the indigo-styled-but-unlinked plain text `format_table_cell`
|
|
740
|
+
# used to render on its own.
|
|
741
|
+
#
|
|
742
|
+
# `item_display_title` can raise on a host record whose `name`/`title`
|
|
743
|
+
# method blows up, and `auto_admin_suite_path_for` already swallows its
|
|
744
|
+
# own errors (unpersisted records, no registered resource, etc.) -- but
|
|
745
|
+
# this wraps the whole thing anyway so one bad row degrades to a plain
|
|
746
|
+
# dash instead of 500ing the entire index or show page.
|
|
747
|
+
#
|
|
748
|
+
# @param value [ActiveRecord::Base]
|
|
749
|
+
# @return [String, ActiveSupport::SafeBuffer]
|
|
750
|
+
def render_association_value(value)
|
|
751
|
+
title = begin
|
|
752
|
+
item_display_title(value).to_s
|
|
753
|
+
rescue StandardError
|
|
754
|
+
"—"
|
|
755
|
+
end
|
|
756
|
+
|
|
757
|
+
path = auto_admin_suite_path_for(value)
|
|
758
|
+
path ? link_to(title, path, class: "text-indigo-600 hover:underline") : title
|
|
759
|
+
rescue StandardError
|
|
760
|
+
"—"
|
|
761
|
+
end
|
|
762
|
+
private :render_association_value
|
|
763
|
+
|
|
625
764
|
def item_display_title(item)
|
|
626
765
|
return item.name if item.respond_to?(:name) && item.name.present?
|
|
627
766
|
return item.title if item.respond_to?(:title) && item.title.present?
|
|
@@ -729,10 +868,48 @@ module AdminSuite
|
|
|
729
868
|
end
|
|
730
869
|
end
|
|
731
870
|
|
|
871
|
+
# Resolves the default search URL for a `searchable_select` field
|
|
872
|
+
# declared with `resource:` (e.g. `field :company_id,
|
|
873
|
+
# type: :searchable_select, resource: :companies`), so it can reach the
|
|
874
|
+
# gem's own search endpoint (`ResourcesController#search`) without the
|
|
875
|
+
# host wiring up a `collection:` URL by hand. A String `collection:`
|
|
876
|
+
# option always takes precedence over this -- see `render_searchable_select`.
|
|
877
|
+
#
|
|
878
|
+
# Looks up the resource by its plural (or singular) resource name among
|
|
879
|
+
# `Admin::Base::Resource.registered_resources`; returns nil (never raises)
|
|
880
|
+
# when the key is blank, unregistered, or has no `portal_name` to route
|
|
881
|
+
# through, so a typo'd `resource:` degrades to "no remote search" rather
|
|
882
|
+
# than a 500 on the very page meant to render a form.
|
|
883
|
+
#
|
|
884
|
+
# @param resource_key [Symbol, String, nil]
|
|
885
|
+
# @return [String, nil]
|
|
886
|
+
def admin_suite_search_url_for(resource_key)
|
|
887
|
+
return nil if resource_key.blank?
|
|
888
|
+
|
|
889
|
+
AdminSuite::DefinitionLoader.load!(:resources)
|
|
890
|
+
key = resource_key.to_s
|
|
891
|
+
target = Admin::Base::Resource.registered_resources.find do |r|
|
|
892
|
+
r.resource_name_plural == key || r.resource_name == key
|
|
893
|
+
end
|
|
894
|
+
return nil unless target&.portal_name
|
|
895
|
+
|
|
896
|
+
search_resources_path(portal: target.portal_name, resource_name: target.resource_name_plural)
|
|
897
|
+
rescue StandardError => e
|
|
898
|
+
Rails.logger&.warn(
|
|
899
|
+
"AdminSuite: resolving the search URL for `resource: #{resource_key.inspect}` raised " \
|
|
900
|
+
"#{e.class}: #{e.message}; rendering the field with no remote search."
|
|
901
|
+
)
|
|
902
|
+
nil
|
|
903
|
+
end
|
|
904
|
+
|
|
732
905
|
def render_searchable_select(_f, field, resource)
|
|
733
906
|
param_key = resource.class.model_name.param_key
|
|
734
907
|
current_value = resource.public_send(field.name)
|
|
735
908
|
collection = field.collection.is_a?(Proc) ? field.collection.call : field.collection
|
|
909
|
+
# A String `collection:` is an explicit search-URL override and always
|
|
910
|
+
# wins (unchanged host behavior); otherwise fall back to the field's
|
|
911
|
+
# `resource:` option resolved via `admin_suite_search_url_for`.
|
|
912
|
+
search_url = collection.is_a?(String) ? collection : admin_suite_search_url_for(field.resource).to_s
|
|
736
913
|
|
|
737
914
|
options_json = if collection.is_a?(Array)
|
|
738
915
|
collection.map { |opt| opt.is_a?(Array) ? { value: opt[1], label: opt[0] } : { value: opt, label: opt.to_s.humanize } }.to_json
|
|
@@ -762,7 +939,7 @@ module AdminSuite
|
|
|
762
939
|
controller: "admin-suite--searchable-select",
|
|
763
940
|
"admin-suite--searchable-select-options-value": options_json,
|
|
764
941
|
"admin-suite--searchable-select-creatable-value": field.create_url.present?,
|
|
765
|
-
"admin-suite--searchable-select-search-url-value":
|
|
942
|
+
"admin-suite--searchable-select-search-url-value": search_url,
|
|
766
943
|
"admin-suite--searchable-select-create-url-value": field.create_url.to_s
|
|
767
944
|
},
|
|
768
945
|
class: "relative") do
|
|
@@ -42,3 +42,6 @@ application.register("admin-suite--flash", FlashController)
|
|
|
42
42
|
|
|
43
43
|
import DependentSearchableSelectController from "controllers/admin_suite/dependent_searchable_select_controller"
|
|
44
44
|
application.register("admin-suite--dependent-searchable-select", DependentSearchableSelectController)
|
|
45
|
+
|
|
46
|
+
import ChartController from "controllers/admin_suite/chart_controller"
|
|
47
|
+
application.register("admin-suite--chart", ChartController)
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Chart Controller (Admin Suite)
|
|
5
|
+
*
|
|
6
|
+
* Upgrades a server-rendered CSS bar chart into a real Chart.js canvas.
|
|
7
|
+
* Chart.js is loaded from the engine's vendored asset (see app/assets/vendor)
|
|
8
|
+
* only on pages that render a chart panel with data.
|
|
9
|
+
*
|
|
10
|
+
* The server-rendered bar markup (this.element's existing children) is the
|
|
11
|
+
* no-JS degraded state. It stays on the page and visible until Chart.js is
|
|
12
|
+
* confirmed available and a canvas has actually been drawn — if Chart.js
|
|
13
|
+
* never loads, the bars are simply never hidden.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
// Bounded retry: ~50 attempts at 100ms each (~5s) before giving up. Guards
|
|
17
|
+
// against an unbounded poll if the Chart.js asset fails to load (e.g. blocked
|
|
18
|
+
// by a CSP, 404, or a page that never actually included it).
|
|
19
|
+
const MAX_INIT_ATTEMPTS = 50
|
|
20
|
+
const INIT_RETRY_DELAY_MS = 100
|
|
21
|
+
|
|
22
|
+
const COLOR_HEX = {
|
|
23
|
+
amber: "#f59e0b",
|
|
24
|
+
green: "#22c55e",
|
|
25
|
+
red: "#ef4444",
|
|
26
|
+
cyan: "#06b6d4",
|
|
27
|
+
violet: "#8b5cf6",
|
|
28
|
+
indigo: "#6366f1",
|
|
29
|
+
slate: "#64748b",
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const DOUGHNUT_PALETTE = [
|
|
33
|
+
"#6366f1", "#f59e0b", "#22c55e", "#ef4444",
|
|
34
|
+
"#06b6d4", "#8b5cf6", "#64748b", "#ec4899",
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
// Mirrors the ERB partial's own `chart_types` allowlist (see
|
|
38
|
+
// _chart.html.erb). The ERB already normalizes an unknown `type:` down to
|
|
39
|
+
// "bar" before it reaches the DOM, so this is a defensive second check, not
|
|
40
|
+
// the primary one — it only matters if something other than the shipped
|
|
41
|
+
// partial writes the type-value attribute (e.g. a host's `panel_chart`
|
|
42
|
+
// override that skips the ERB's own validation).
|
|
43
|
+
const KNOWN_TYPES = [ "bar", "line", "area", "doughnut" ]
|
|
44
|
+
|
|
45
|
+
// Fallback height (px) if the server didn't send a height value for some
|
|
46
|
+
// reason. Kept in sync with the ERB partial's own default.
|
|
47
|
+
const DEFAULT_HEIGHT_PX = 192
|
|
48
|
+
|
|
49
|
+
export default class extends Controller {
|
|
50
|
+
static values = {
|
|
51
|
+
series: Array,
|
|
52
|
+
type: String,
|
|
53
|
+
color: String,
|
|
54
|
+
height: Number,
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
connect() {
|
|
58
|
+
this.initAttempts = 0
|
|
59
|
+
this.chart = null
|
|
60
|
+
this.canvasWrapper = null
|
|
61
|
+
this.initChart()
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
initChart() {
|
|
65
|
+
if (typeof window.Chart === "undefined") {
|
|
66
|
+
this.initAttempts += 1
|
|
67
|
+
if (this.initAttempts >= MAX_INIT_ATTEMPTS) {
|
|
68
|
+
console.warn(
|
|
69
|
+
"admin-suite--chart: Chart.js failed to load after " +
|
|
70
|
+
MAX_INIT_ATTEMPTS +
|
|
71
|
+
" attempts; leaving the server-rendered bar chart in place."
|
|
72
|
+
)
|
|
73
|
+
return
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
this.retryTimeout = setTimeout(() => this.initChart(), INIT_RETRY_DELAY_MS)
|
|
77
|
+
return
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Idempotence guard: never build a second chart on top of an existing one.
|
|
81
|
+
if (this.chart) return
|
|
82
|
+
|
|
83
|
+
const series = this.hasSeriesValue ? this.seriesValue : []
|
|
84
|
+
if (series.length === 0) return
|
|
85
|
+
|
|
86
|
+
let type = this.hasTypeValue && this.typeValue ? this.typeValue : "bar"
|
|
87
|
+
if (!KNOWN_TYPES.includes(type)) {
|
|
88
|
+
console.warn(
|
|
89
|
+
"admin-suite--chart: unknown chart type " + JSON.stringify(type) + "; falling back to \"bar\"."
|
|
90
|
+
)
|
|
91
|
+
type = "bar"
|
|
92
|
+
}
|
|
93
|
+
const color = COLOR_HEX[this.colorValue] || COLOR_HEX.indigo
|
|
94
|
+
const height = this.hasHeightValue && this.heightValue > 0 ? this.heightValue : DEFAULT_HEIGHT_PX
|
|
95
|
+
|
|
96
|
+
// Chart.js's `responsive: true` / `maintainAspectRatio: false` combo
|
|
97
|
+
// requires the canvas's parent to have an explicit, non-content-derived
|
|
98
|
+
// height — otherwise there is no reference box to size against, and the
|
|
99
|
+
// canvas can render at 0px or grow unboundedly. The wrapper's height
|
|
100
|
+
// matches the server-rendered bars' height exactly (same data value), so
|
|
101
|
+
// there's no layout shift when the canvas replaces them.
|
|
102
|
+
const wrapper = document.createElement("div")
|
|
103
|
+
wrapper.style.position = "relative"
|
|
104
|
+
wrapper.style.height = `${height}px`
|
|
105
|
+
wrapper.style.width = "100%"
|
|
106
|
+
|
|
107
|
+
const canvas = document.createElement("canvas")
|
|
108
|
+
canvas.setAttribute("role", "img")
|
|
109
|
+
canvas.setAttribute("aria-label", "Chart")
|
|
110
|
+
wrapper.appendChild(canvas)
|
|
111
|
+
|
|
112
|
+
// Attach to the document before constructing the Chart so Chart.js can
|
|
113
|
+
// actually measure the wrapper's box on first render.
|
|
114
|
+
this.element.appendChild(wrapper)
|
|
115
|
+
|
|
116
|
+
const labels = series.map((row) => row.label)
|
|
117
|
+
const values = series.map((row) => row.value)
|
|
118
|
+
|
|
119
|
+
const chartType = type === "area" ? "line" : type
|
|
120
|
+
const isDoughnut = chartType === "doughnut"
|
|
121
|
+
|
|
122
|
+
const dataset = {
|
|
123
|
+
data: values,
|
|
124
|
+
backgroundColor: isDoughnut ? DOUGHNUT_PALETTE : color,
|
|
125
|
+
borderColor: isDoughnut ? DOUGHNUT_PALETTE : color,
|
|
126
|
+
fill: type === "area",
|
|
127
|
+
tension: 0.3,
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Only hide the degraded bar markup once Chart.js has actually accepted
|
|
131
|
+
// the config and rendered — this is what keeps the CSS bars visible as
|
|
132
|
+
// the degraded state on any earlier failure path.
|
|
133
|
+
this.chart = new window.Chart(canvas.getContext("2d"), {
|
|
134
|
+
type: chartType,
|
|
135
|
+
data: { labels, datasets: [ dataset ] },
|
|
136
|
+
options: {
|
|
137
|
+
responsive: true,
|
|
138
|
+
maintainAspectRatio: false,
|
|
139
|
+
plugins: { legend: { display: isDoughnut } },
|
|
140
|
+
scales: isDoughnut ? {} : { y: { beginAtZero: true } },
|
|
141
|
+
},
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
this.canvasWrapper = wrapper
|
|
145
|
+
|
|
146
|
+
// Hide (not remove) the server-rendered bar markup so it can be restored
|
|
147
|
+
// by simply removing the canvas wrapper if the controller disconnects.
|
|
148
|
+
Array.from(this.element.children).forEach((child) => {
|
|
149
|
+
if (child !== wrapper) child.style.display = "none"
|
|
150
|
+
})
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
disconnect() {
|
|
154
|
+
if (this.retryTimeout) {
|
|
155
|
+
clearTimeout(this.retryTimeout)
|
|
156
|
+
this.retryTimeout = null
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (this.chart) {
|
|
160
|
+
this.chart.destroy()
|
|
161
|
+
this.chart = null
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (this.canvasWrapper) {
|
|
165
|
+
this.canvasWrapper.remove()
|
|
166
|
+
this.canvasWrapper = null
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
Array.from(this.element.children).forEach((child) => {
|
|
170
|
+
child.style.display = ""
|
|
171
|
+
})
|
|
172
|
+
}
|
|
173
|
+
}
|