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
|
@@ -7,7 +7,15 @@ module AdminSuite
|
|
|
7
7
|
|
|
8
8
|
before_action :require_resource_config!
|
|
9
9
|
before_action :enforce_read_only!, only: %i[new create edit update destroy toggle]
|
|
10
|
-
|
|
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
|
|
@@ -23,6 +31,26 @@ module AdminSuite
|
|
|
23
31
|
def show
|
|
24
32
|
end
|
|
25
33
|
|
|
34
|
+
# GET /:portal/:resource_name/search?q=term
|
|
35
|
+
#
|
|
36
|
+
# Feeds `searchable_select_controller.js`'s `fetchOptions`, which expects
|
|
37
|
+
# a bare JSON array (not `{results: [...]}`) of objects each carrying
|
|
38
|
+
# `id`/`value` and `name`/`title`/`label`. Matches that contract exactly
|
|
39
|
+
# -- the JS needs no changes.
|
|
40
|
+
#
|
|
41
|
+
# Reuses `FilterBuilder.search_predicate`, the same ILIKE-over-
|
|
42
|
+
# `searchable_fields` logic the index's own search box uses, so this can
|
|
43
|
+
# only ever search the resource's declared `searchable` whitelist --
|
|
44
|
+
# never an arbitrary column supplied via `q`. `require_resource_config!`
|
|
45
|
+
# and `authorize_admin_suite!` (both already-registered before_actions,
|
|
46
|
+
# the latter driven by `AUTHORIZATION_VERBS["search"] = :read` below)
|
|
47
|
+
# gate this exactly like every other action on this controller: unknown
|
|
48
|
+
# resource names 404 before either runs, and a denying `config.authorize`
|
|
49
|
+
# 403s before any query executes.
|
|
50
|
+
def search
|
|
51
|
+
render json: search_results.first(SEARCH_RESULT_LIMIT).map { |record| search_result_json(record) }
|
|
52
|
+
end
|
|
53
|
+
|
|
26
54
|
# GET /:portal/:resource_name/new
|
|
27
55
|
def new
|
|
28
56
|
@resource = resource_class.new
|
|
@@ -129,15 +157,49 @@ module AdminSuite
|
|
|
129
157
|
|
|
130
158
|
private
|
|
131
159
|
|
|
160
|
+
# Hard cap on searchable_select results, regardless of table size or how
|
|
161
|
+
# permissive the resource's `searchable` list is.
|
|
162
|
+
SEARCH_RESULT_LIMIT = 25
|
|
163
|
+
|
|
132
164
|
# Controller action -> authorization verb.
|
|
133
165
|
AUTHORIZATION_VERBS = {
|
|
134
|
-
"index" => :read, "show" => :read,
|
|
166
|
+
"index" => :read, "show" => :read, "search" => :read,
|
|
135
167
|
"new" => :create, "create" => :create,
|
|
136
168
|
"edit" => :update, "update" => :update, "toggle" => :update,
|
|
137
169
|
"destroy" => :destroy,
|
|
138
170
|
"execute_action" => :execute, "bulk_action" => :execute
|
|
139
171
|
}.freeze
|
|
140
172
|
|
|
173
|
+
# Filters the resource's own records by `params[:q]` via the shared
|
|
174
|
+
# `FilterBuilder` predicate. A nil predicate (blank `q`, no index config,
|
|
175
|
+
# no declared `searchable` fields, or `q` shorter than
|
|
176
|
+
# `FilterBuilder::MIN_SEARCH_LENGTH`) means no results at all here --
|
|
177
|
+
# deliberately *not* the "fall back to unfiltered scope" behavior
|
|
178
|
+
# `FilterBuilder#apply_search` uses for the index filter box. A raw JSON
|
|
179
|
+
# endpoint must never hand back arbitrary rows just because the query
|
|
180
|
+
# was empty or the resource isn't configured for search.
|
|
181
|
+
#
|
|
182
|
+
# @return [Enumerable]
|
|
183
|
+
def search_results
|
|
184
|
+
predicate = Admin::Base::FilterBuilder.search_predicate(resource_config&.index_config, params[:q])
|
|
185
|
+
return [] unless predicate
|
|
186
|
+
|
|
187
|
+
conditions, search_term = predicate
|
|
188
|
+
resource_class.where(conditions, search: search_term)
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# @param record [Object]
|
|
192
|
+
# @return [Hash]
|
|
193
|
+
def search_result_json(record)
|
|
194
|
+
{ id: record.id, name: search_result_label(record) }
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def search_result_label(record)
|
|
198
|
+
return record.name if record.respond_to?(:name) && record.name.present?
|
|
199
|
+
return record.title if record.respond_to?(:title) && record.title.present?
|
|
200
|
+
record.to_s
|
|
201
|
+
end
|
|
202
|
+
|
|
141
203
|
# Enforces the host's `config.authorize` hook. Nil hook = allowed
|
|
142
204
|
# (authentication remains the gate). Falsy return = 403.
|
|
143
205
|
#
|
|
@@ -222,12 +284,75 @@ module AdminSuite
|
|
|
222
284
|
def filtered_collection
|
|
223
285
|
return resource_class.all unless resource_config&.index_config
|
|
224
286
|
|
|
225
|
-
Admin::Base::FilterBuilder.new(resource_config, params).apply(resource_class.all)
|
|
287
|
+
scope = Admin::Base::FilterBuilder.new(resource_config, params).apply(resource_class.all)
|
|
288
|
+
apply_index_includes(scope)
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
# Applies the index's `includes:` DSL option (see
|
|
292
|
+
# `Admin::Base::Resource::IndexConfig#includes`) to the filtered scope.
|
|
293
|
+
# Only when the scope actually responds to `#includes` -- the PORO
|
|
294
|
+
# `Relation` test doubles used throughout this gem's own test suite
|
|
295
|
+
# don't, and a host's own non-AR scope object may not either -- so this
|
|
296
|
+
# skips silently rather than raising. `.includes` itself can raise for
|
|
297
|
+
# a bad/renamed/typo'd association name once the scope is a real AR
|
|
298
|
+
# relation; that must degrade the index to an unoptimized-but-working
|
|
299
|
+
# page, not 500 it, so it's logged and swallowed the same way the
|
|
300
|
+
# chart panel's bad `type:`/`data` values are (see
|
|
301
|
+
# `app/views/admin_suite/panels/_chart.html.erb`).
|
|
302
|
+
#
|
|
303
|
+
# @param scope [Object] the filtered collection
|
|
304
|
+
# @return [Object] the scope, with associations eager-loaded when possible
|
|
305
|
+
def apply_index_includes(scope)
|
|
306
|
+
includes_list = resource_config.index_config.includes_list
|
|
307
|
+
return scope if includes_list.blank?
|
|
308
|
+
return scope unless scope.respond_to?(:includes)
|
|
309
|
+
|
|
310
|
+
scope.includes(*includes_list)
|
|
311
|
+
rescue StandardError => e
|
|
312
|
+
Rails.logger&.warn(
|
|
313
|
+
"AdminSuite: #{resource_class}'s index `includes(#{includes_list.inspect})` raised " \
|
|
314
|
+
"#{e.class}: #{e.message}; rendering the index without eager loading."
|
|
315
|
+
)
|
|
316
|
+
scope
|
|
226
317
|
end
|
|
227
318
|
|
|
319
|
+
# Max a request can push the index's per-page count to, regardless of
|
|
320
|
+
# what `per_page` the query string carries -- `per_page` is user-supplied
|
|
321
|
+
# (a plain query param), so this exists to stop `?per_page=999999` from
|
|
322
|
+
# turning the index into an unbounded query.
|
|
323
|
+
MAX_PER_PAGE = 100
|
|
324
|
+
|
|
228
325
|
def paginate_collection(scope)
|
|
229
|
-
|
|
230
|
-
|
|
326
|
+
dsl_per_page = resource_config&.index_config&.per_page || 25
|
|
327
|
+
# Pagy 9.x's vars key is `limit:`, not `items:` -- the pre-existing
|
|
328
|
+
# `items:` call silently did nothing (pagy fell through to its own
|
|
329
|
+
# `DEFAULT[:limit]` of 20), so every resource's `paginate(n)` DSL
|
|
330
|
+
# value was already being ignored before this task. Fixed here since
|
|
331
|
+
# this task's clamp is meaningless without it.
|
|
332
|
+
pagy(scope, limit: clamped_per_page(dsl_per_page))
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
# Resolves the effective per-page count for the index from the
|
|
336
|
+
# `per_page` query param, clamped to `MAX_PER_PAGE` and falling back to
|
|
337
|
+
# the DSL's `paginate(n)` value (`dsl_per_page`) whenever the param is
|
|
338
|
+
# absent or not a usable positive integer.
|
|
339
|
+
#
|
|
340
|
+
# `per_page` is the most directly attacker-influenceable input this
|
|
341
|
+
# phase adds, so every shape it can arrive in is handled without
|
|
342
|
+
# raising: missing (nil), non-numeric ("abc"), zero, negative, an
|
|
343
|
+
# array (`per_page[]=1`, which Rails hands back as a plain Array, not
|
|
344
|
+
# a String -- `Integer(Array)` raises `TypeError`), and absurdly large
|
|
345
|
+
# (clamped, never passed through to the query).
|
|
346
|
+
#
|
|
347
|
+
# @param dsl_per_page [Integer] the resource's `paginate(n)` value
|
|
348
|
+
# @return [Integer]
|
|
349
|
+
def clamped_per_page(dsl_per_page)
|
|
350
|
+
value = Integer(params[:per_page])
|
|
351
|
+
return dsl_per_page if value <= 0
|
|
352
|
+
|
|
353
|
+
value.clamp(..MAX_PER_PAGE)
|
|
354
|
+
rescue ArgumentError, TypeError
|
|
355
|
+
dsl_per_page
|
|
231
356
|
end
|
|
232
357
|
|
|
233
358
|
def calculate_stats(scope)
|
|
@@ -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
|
+
}
|