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.
Files changed (39) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +134 -7
  3. data/app/assets/vendor/chart.umd.min.js +14 -0
  4. data/app/controllers/admin_suite/resources_controller.rb +130 -5
  5. data/app/helpers/admin_suite/base_helper.rb +187 -10
  6. data/app/javascript/admin_suite_application.js +3 -0
  7. data/app/javascript/controllers/admin_suite/chart_controller.js +173 -0
  8. data/app/views/admin_suite/panels/_chart.html.erb +158 -18
  9. data/app/views/admin_suite/resources/index.html.erb +24 -5
  10. data/app/views/admin_suite/shared/_sidebar.html.erb +14 -8
  11. data/app/views/layouts/admin_suite/application.html.erb +6 -0
  12. data/config/routes.rb +3 -0
  13. data/lib/admin/base/filter_builder.rb +48 -5
  14. data/lib/admin/base/resource.rb +43 -7
  15. data/lib/admin_suite/configuration.rb +10 -6
  16. data/lib/admin_suite/engine.rb +1 -1
  17. data/lib/admin_suite/legacy_custom_renderer_procs.rb +1 -1
  18. data/lib/admin_suite/renderer_registry.rb +11 -0
  19. data/lib/admin_suite/renderers/legacy_gleania.rb +7 -5
  20. data/lib/admin_suite/ui/dashboard_definition.rb +6 -0
  21. data/lib/admin_suite/version.rb +1 -1
  22. data/lib/admin_suite.rb +13 -2
  23. data/test/integration/association_linking_test.rb +292 -0
  24. data/test/integration/chart_panel_test.rb +491 -0
  25. data/test/integration/dashboard_test.rb +9 -2
  26. data/test/integration/index_table_test.rb +289 -0
  27. data/test/integration/navigation_sections_test.rb +21 -0
  28. data/test/integration/searchable_select_search_test.rb +368 -0
  29. data/test/integration/show_hide_blank_test.rb +195 -0
  30. data/test/integration/toggle_test.rb +106 -0
  31. data/test/lib/definition_loader_test.rb +12 -0
  32. data/test/lib/form_field_renderer_test.rb +83 -11
  33. data/test/lib/format_table_cell_test.rb +49 -0
  34. data/test/lib/index_includes_test.rb +223 -0
  35. data/test/lib/legacy_renderer_deprecation_test.rb +8 -1
  36. data/test/lib/renderer_test.rb +23 -7
  37. data/test/lib/resource_exportable_deprecation_test.rb +10 -2
  38. data/test/test_helper.rb +47 -8
  39. 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
- before_action :set_resource, if: -> { params[:id].present? && !%w[index new create].include?(action_name) }
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
- per_page = resource_config&.index_config&.per_page || 25
230
- pagy(scope, items: per_page)
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
- ensure_admin_resources_loaded_for!(item.class)
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 ? render_sidebar_fields(resource, section.fields) : render_main_fields(resource, section.fields)
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 value.is_a?(ActiveStorage::Attached::One) || value.is_a?(ActiveStorage::Attached::Many)
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 item_display_title(value) if active_record_base?(value)
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": collection.is_a?(String) ? collection : "",
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
+ }