admin_suite 0.3.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +210 -1
  3. data/app/assets/vendor/chart.umd.min.js +14 -0
  4. data/app/assets/vendor/easymde.min.css +7 -0
  5. data/app/assets/vendor/easymde.min.js +7 -0
  6. data/app/controllers/admin_suite/application_controller.rb +26 -47
  7. data/app/controllers/admin_suite/portals_controller.rb +2 -2
  8. data/app/controllers/admin_suite/resources_controller.rb +138 -6
  9. data/app/helpers/admin_suite/base_helper.rb +232 -391
  10. data/app/javascript/admin_suite_application.js +3 -0
  11. data/app/javascript/controllers/admin_suite/chart_controller.js +173 -0
  12. data/app/javascript/controllers/admin_suite/markdown_editor_controller.js +21 -2
  13. data/app/views/admin_suite/panels/_chart.html.erb +158 -18
  14. data/app/views/admin_suite/panels/_stat.html.erb +10 -0
  15. data/app/views/admin_suite/resources/index.html.erb +31 -82
  16. data/app/views/admin_suite/shared/_pagination.html.erb +74 -0
  17. data/app/views/admin_suite/shared/_sidebar.html.erb +15 -9
  18. data/app/views/layouts/admin_suite/application.html.erb +11 -3
  19. data/config/routes.rb +3 -0
  20. data/lib/admin/base/action_executor.rb +19 -51
  21. data/lib/admin/base/filter_builder.rb +48 -5
  22. data/lib/admin/base/resource.rb +92 -17
  23. data/lib/admin_suite/configuration.rb +32 -3
  24. data/lib/admin_suite/definition_loader.rb +194 -0
  25. data/lib/admin_suite/deprecation.rb +48 -0
  26. data/lib/admin_suite/engine.rb +55 -76
  27. data/lib/admin_suite/host_autoload_policy.rb +132 -0
  28. data/lib/admin_suite/legacy_custom_renderer_procs.rb +29 -0
  29. data/lib/admin_suite/portal_definition.rb +11 -0
  30. data/lib/admin_suite/renderer.rb +133 -0
  31. data/lib/admin_suite/renderer_registry.rb +81 -0
  32. data/lib/admin_suite/renderers/code_renderer.rb +15 -0
  33. data/lib/admin_suite/renderers/json_renderer.rb +15 -0
  34. data/lib/admin_suite/renderers/key_value_renderer.rb +39 -0
  35. data/lib/admin_suite/renderers/legacy_gleania.rb +232 -0
  36. data/lib/admin_suite/renderers/table_from_renderer.rb +22 -0
  37. data/lib/admin_suite/section_definition.rb +42 -0
  38. data/lib/admin_suite/ui/dashboard_definition.rb +6 -0
  39. data/lib/admin_suite/ui/field_renderer_registry.rb +31 -4
  40. data/lib/admin_suite/ui/form_field_renderer.rb +1 -7
  41. data/lib/admin_suite/ui/show_formatter_registry.rb +9 -0
  42. data/lib/admin_suite/ui/show_value_formatter.rb +7 -3
  43. data/lib/admin_suite/version.rb +1 -1
  44. data/lib/admin_suite.rb +48 -0
  45. data/lib/generators/admin_suite/install/templates/admin_suite.rb +0 -4
  46. data/test/controllers/resources_controller_test.rb +76 -1
  47. data/test/integration/association_linking_test.rb +292 -0
  48. data/test/integration/chart_panel_test.rb +491 -0
  49. data/test/integration/dashboard_test.rb +9 -2
  50. data/test/integration/index_table_test.rb +289 -0
  51. data/test/integration/layout_assets_test.rb +112 -0
  52. data/test/integration/navigation_sections_test.rb +62 -0
  53. data/test/integration/pagination_and_stats_test.rb +152 -0
  54. data/test/integration/searchable_select_search_test.rb +368 -0
  55. data/test/integration/show_hide_blank_test.rb +195 -0
  56. data/test/integration/toggle_test.rb +106 -0
  57. data/test/lib/action_executor_redirect_test.rb +41 -0
  58. data/test/lib/builtin_renderers_test.rb +202 -0
  59. data/test/lib/definition_loader_test.rb +276 -0
  60. data/test/lib/engine_defaults_test.rb +39 -0
  61. data/test/lib/form_field_renderer_test.rb +136 -0
  62. data/test/lib/format_table_cell_test.rb +49 -0
  63. data/test/lib/index_includes_test.rb +223 -0
  64. data/test/lib/legacy_renderer_deprecation_test.rb +42 -0
  65. data/test/lib/renderer_test.rb +237 -0
  66. data/test/lib/resource_exportable_deprecation_test.rb +47 -0
  67. data/test/lib/show_value_formatter_test.rb +88 -0
  68. data/test/lib/zeitwerk_integration_test.rb +28 -64
  69. data/test/test_helper.rb +121 -5
  70. metadata +62 -5
@@ -6,11 +6,9 @@ module AdminSuite
6
6
  # This is intentionally very close to the `/internal/developer` helper so we can
7
7
  # keep both UIs side-by-side and compare behavior while migrating.
8
8
  module BaseHelper
9
- include Pagy::Frontend
10
9
  include AdminSuite::IconHelper
11
10
  include AdminSuite::PanelsHelper
12
11
  include AdminSuite::ThemeHelper
13
- include ::Internal::Developer::CustomRenderersHelper if defined?(::Internal::Developer::CustomRenderersHelper)
14
12
  # ActiveStorage route helpers live on the host app (main_app), not the isolated engine.
15
13
  def admin_suite_rails_blob_path(...)
16
14
  if respond_to?(:main_app) && main_app.respond_to?(:rails_blob_path)
@@ -86,9 +84,8 @@ module AdminSuite
86
84
  end
87
85
 
88
86
 
89
- # Prefer registry-driven implementations (with legacy fallbacks via `super`).
90
- prepend AdminSuite::UI::ShowValueFormatter
91
- prepend AdminSuite::UI::FormFieldRenderer
87
+ include AdminSuite::UI::ShowValueFormatter
88
+ include AdminSuite::UI::FormFieldRenderer
92
89
 
93
90
  # Returns the color scheme for a portal
94
91
  #
@@ -144,80 +141,43 @@ module AdminSuite
144
141
  elsif column.content.is_a?(Proc)
145
142
  column.content.call(record)
146
143
  else
147
- 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
148
157
  end
149
158
  end
150
159
 
151
- # Formats a value for display on show pages
160
+ # Maps a column's `align:` DSL option to a literal Tailwind class.
152
161
  #
153
- # @param record [ActiveRecord::Base] The record
154
- # @param field_name [Symbol, String] Field name
155
- # @return [String] HTML safe formatted value
156
- def format_show_value(record, field_name)
157
- value = record.public_send(field_name) rescue nil
158
-
159
- if value.is_a?(ActiveStorage::Attached::One)
160
- return render_attachment_preview(value)
161
- elsif value.is_a?(ActiveStorage::Attached::Many)
162
- return render_attachments_preview(value)
163
- end
164
-
165
- case value
166
- when nil
167
- content_tag(:span, "—", class: "text-slate-400")
168
- when true
169
- content_tag(:span, class: "inline-flex items-center gap-1") do
170
- svg = '<svg class="w-4 h-4 text-green-500" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/></svg>'.html_safe
171
- concat(svg)
172
- concat(content_tag(:span, "Yes", class: "text-green-600 font-medium"))
173
- end
174
- when false
175
- content_tag(:span, class: "inline-flex items-center gap-1") do
176
- svg = '<svg class="w-4 h-4 text-slate-400" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"/></svg>'.html_safe
177
- concat(svg)
178
- concat(content_tag(:span, "No", class: "text-slate-500"))
179
- end
180
- when Time, DateTime
181
- content_tag(:span, class: "inline-flex items-center gap-2") do
182
- concat(content_tag(:span, value.strftime("%B %d, %Y at %H:%M"), class: "font-medium"))
183
- concat(content_tag(:span, "(#{time_ago_in_words(value)} ago)", class: "text-slate-500 text-xs"))
184
- end
185
- when Date
186
- value.strftime("%B %d, %Y")
187
- when ActiveRecord::Base
188
- link_text = value.respond_to?(:name) ? value.name : "#{value.class.name} ##{value.id}"
189
- content_tag(:span, link_text, class: "text-indigo-600")
190
- when Hash
191
- render_json_block(value)
192
- when Array
193
- if value.empty?
194
- content_tag(:span, "Empty array", class: "text-slate-400 italic")
195
- elsif value.first.is_a?(Hash)
196
- render_json_block(value)
197
- else
198
- content_tag(:div, class: "flex flex-wrap gap-1") do
199
- value.each do |item|
200
- concat(content_tag(:span, item.to_s, class: "inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-slate-100 text-slate-700"))
201
- end
202
- end
203
- end
204
- when Integer, Float, BigDecimal
205
- content_tag(:span, number_with_delimiter(value), class: "font-mono")
206
- else
207
- value_str = value.to_s
208
-
209
- if value_str.start_with?("{", "[") && value_str.length > 10
210
- begin
211
- parsed = JSON.parse(value_str)
212
- render_json_block(parsed)
213
- rescue JSON::ParserError
214
- render_text_block(value_str)
215
- end
216
- elsif value_str.include?("\n") || value_str.length > 200
217
- render_text_block(value_str, detect_language(field_name, value_str))
218
- else
219
- value_str
220
- end
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 ""
221
181
  end
222
182
  end
223
183
 
@@ -341,213 +301,52 @@ module AdminSuite
341
301
  nil
342
302
  end
343
303
 
344
- def render_custom_section(resource, render_type)
345
- renderer = AdminSuite.config.custom_renderers[render_type.to_sym] rescue nil
346
- return renderer.call(resource, self) if renderer
347
-
348
- case render_type.to_sym
349
- when :prompt_template_preview
350
- render_prompt_template(resource)
351
- when :json_preview
352
- render_json_preview(resource)
353
- when :code_preview
354
- render_code_preview(resource)
355
- when :messages_preview
356
- render_messages_preview(resource)
357
- when :tool_args_preview
358
- render_tool_args_preview(resource)
359
- when :turn_messages_preview
360
- render_turn_messages_preview(resource)
361
- else
362
- content_tag(:p, "Unknown render type: #{render_type}", class: "text-slate-500 italic")
363
- end
364
- end
365
-
366
- # --- generic custom renderers (fallbacks) ---
367
- def render_prompt_template(resource)
368
- template = resource.respond_to?(:prompt_template) ? resource.prompt_template : nil
369
- return content_tag(:p, "No template defined", class: "text-slate-500 italic") if template.blank?
304
+ def render_custom_section(resource, render_type, options = {})
305
+ key = render_type.to_sym
370
306
 
371
- highlighted_template = h(template).gsub(/\{\{(\w+)\}\}/) do
372
- "<span class=\"text-amber-400 bg-amber-900/30 px-1 rounded\">{{#{$1}}}</span>"
307
+ legacy_proc = AdminSuite.config.custom_renderers[key]
308
+ if legacy_proc
309
+ AdminSuite::LegacyCustomRendererProcs.warn_once(key)
310
+ return legacy_proc.call(resource, self)
373
311
  end
374
312
 
375
- content_tag(:div, class: "relative group") do
376
- concat(content_tag(:div, class: "absolute top-2 right-2 flex items-center gap-2") do
377
- concat(content_tag(:span, "TEMPLATE", class: "text-xs font-medium text-slate-400 uppercase tracking-wider"))
378
- concat(content_tag(:button,
379
- '<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/></svg>'.html_safe,
380
- type: "button",
381
- class: "p-1 text-slate-400 hover:text-slate-600 opacity-0 group-hover:opacity-100 transition-opacity",
382
- data: { controller: "admin-suite--clipboard", action: "click->admin-suite--clipboard#copy", "admin-suite--clipboard-text-value": template },
383
- title: "Copy to clipboard"))
384
- end)
385
-
386
- concat(content_tag(:pre, class: "bg-slate-900 text-slate-100 p-4 rounded-lg overflow-x-auto text-sm font-mono max-h-[600px] overflow-y-auto whitespace-pre-wrap leading-relaxed") do
387
- highlighted_template.html_safe
388
- end)
389
-
390
- variables = template.scan(/\{\{(\w+)\}\}/).flatten.uniq
391
- if variables.any?
392
- concat(content_tag(:div, class: "mt-3 pt-3 border-t border-slate-700") do
393
- concat(content_tag(:span, "Variables: ", class: "text-sm text-slate-400"))
394
- concat(content_tag(:div, class: "inline-flex flex-wrap gap-1 mt-1") do
395
- variables.each do |var|
396
- concat(content_tag(:code, "{{#{var}}}", class: "text-xs px-2 py-0.5 bg-amber-900/30 text-amber-400 rounded"))
397
- end
398
- end)
399
- end)
400
- end
401
- end
402
- end
403
-
404
- def render_json_preview(resource)
405
- data = resource.respond_to?(:data) ? resource.data : resource.attributes
406
- render_json_block(data)
407
- end
408
-
409
- def render_code_preview(resource)
410
- code = resource.respond_to?(:code) ? resource.code : resource.to_s
411
- render_text_block(code, :ruby)
412
- end
413
-
414
- def render_messages_preview(resource)
415
- messages = resource.respond_to?(:messages) ? resource.messages : []
416
- if messages.respond_to?(:chronological)
417
- messages = messages.chronological
418
- end
419
- messages = messages.limit(50) if messages.respond_to?(:limit)
420
- messages = Array.wrap(messages)
421
-
422
- return content_tag(:p, "No messages", class: "text-slate-500 italic") if messages.blank?
423
-
424
- content_tag(:div, class: "space-y-4 max-h-[600px] overflow-y-auto -mx-6 -mb-6 p-6 pt-0") do
425
- messages.each_with_index do |msg, idx|
426
- if msg.respond_to?(:role)
427
- role = msg.role
428
- content = msg.content
429
- created_at = msg.respond_to?(:created_at) ? msg.created_at : nil
430
- else
431
- role = msg["role"] || msg[:role] || "unknown"
432
- content = msg["content"] || msg[:content] || ""
433
- created_at = msg["created_at"] || msg[:created_at]
434
- end
435
-
436
- role_class = case role.to_s
437
- when "user" then "bg-blue-50 border-blue-200"
438
- when "assistant" then "bg-emerald-50 border-emerald-200"
439
- when "tool" then "bg-amber-50 border-amber-200"
440
- when "system" then "bg-slate-50 border-slate-200"
441
- else "bg-slate-50 border-slate-200"
442
- end
443
-
444
- role_icon = case role.to_s
445
- when "user"
446
- '<svg class="w-4 h-4 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/></svg>'.html_safe
447
- when "assistant"
448
- '<svg class="w-4 h-4 text-emerald-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z"/></svg>'.html_safe
449
- when "tool"
450
- '<svg class="w-4 h-4 text-amber-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/></svg>'.html_safe
451
- else
452
- '<svg class="w-4 h-4 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"/></svg>'.html_safe
453
- end
454
-
455
- concat(content_tag(:div, class: "rounded-lg border p-4 #{role_class}") do
456
- concat(content_tag(:div, class: "flex items-center justify-between mb-3") do
457
- concat(content_tag(:div, class: "flex items-center gap-2") do
458
- concat(role_icon)
459
- concat(content_tag(:span, role.to_s.capitalize, class: "text-sm font-medium text-slate-700"))
460
- end)
461
- concat(content_tag(:div, class: "flex items-center gap-2 text-xs text-slate-400") do
462
- concat(content_tag(:span, created_at.strftime("%H:%M:%S"))) if created_at.respond_to?(:strftime)
463
- concat(content_tag(:span, "##{idx + 1}"))
464
- end)
465
- end)
466
-
467
- content_str = content.to_s
468
- if role.to_s == "tool" && content_str.start_with?("{", "[")
469
- begin
470
- parsed = JSON.parse(content_str)
471
- concat(render_json_block(parsed))
472
- rescue JSON::ParserError
473
- concat(content_tag(:div, simple_format(h(content_str)), class: "prose prose-sm max-w-none"))
474
- end
475
- else
476
- concat(content_tag(:div, simple_format(h(content_str)), class: "prose prose-sm max-w-none"))
477
- end
478
- end)
479
- end
480
- end
313
+ # Precedence, after the legacy proc above (unchanged, Task 3 has tests
314
+ # pinning that): a host's explicit registration, then a host renderer
315
+ # class, then the gem's own defaults (built-ins + the deprecated four
316
+ # Gleania renderers). Checking the gem defaults last means a host that
317
+ # defines `Admin::Renderers::<Key>Renderer` -- exactly the migration
318
+ # path the deprecation warnings recommend -- actually takes effect
319
+ # instead of being silently shadowed by the gem's boot-time
320
+ # registrations (see `RendererRegistry`).
321
+ klass = AdminSuite::RendererRegistry.lookup(key) ||
322
+ host_renderer_class(key) ||
323
+ AdminSuite::RendererRegistry.lookup_default(key)
324
+ return klass.new(resource, self, options).render if klass
325
+
326
+ content_tag(:p, "Unknown render type: #{render_type}", class: "text-slate-500 italic")
481
327
  end
482
328
 
483
- def render_tool_args_preview(resource)
484
- args = resource.respond_to?(:args) ? resource.args : (resource.respond_to?(:arguments) ? resource.arguments : {})
485
- result = resource.respond_to?(:result) ? resource.result : nil
486
- error = resource.respond_to?(:error) ? resource.error : nil
487
-
488
- content_tag(:div, class: "space-y-6") do
489
- concat(content_tag(:div) do
490
- concat(content_tag(:h4, "Arguments", class: "text-sm font-medium text-slate-500 mb-2"))
491
- if args.present? && args != {}
492
- concat(render_json_block(args))
493
- else
494
- concat(content_tag(:p, "No arguments", class: "text-slate-400 italic text-sm"))
495
- end
496
- end)
497
-
498
- if result.present? && result != {}
499
- concat(content_tag(:div, class: "pt-4 border-t border-slate-200") do
500
- concat(content_tag(:h4, "Result", class: "text-sm font-medium text-slate-500 mb-2"))
501
- concat(render_json_block(result))
502
- end)
503
- end
504
-
505
- if error.present?
506
- concat(content_tag(:div, class: "pt-4 border-t border-slate-200") do
507
- concat(content_tag(:h4, "Error", class: "text-sm font-medium text-red-500 mb-2"))
508
- concat(content_tag(:div, class: "bg-red-50 border border-red-200 rounded-lg p-4") do
509
- content_tag(:pre, h(error.to_s), class: "text-sm text-red-700 whitespace-pre-wrap font-mono")
510
- end)
511
- end)
512
- end
513
- end
329
+ # Resolves `Admin::Renderers::<Key>Renderer` in the host app, if defined.
330
+ def host_renderer_class(key)
331
+ "Admin::Renderers::#{key.to_s.camelize}Renderer".safe_constantize
514
332
  end
515
333
 
516
- def render_turn_messages_preview(resource)
517
- user_msg = resource.respond_to?(:user_message) ? resource.user_message : nil
518
- asst_msg = resource.respond_to?(:assistant_message) ? resource.assistant_message : nil
519
-
520
- content_tag(:div, class: "space-y-4") do
521
- if user_msg
522
- concat(content_tag(:div, class: "rounded-lg border p-4 bg-blue-50 border-blue-200") do
523
- concat(content_tag(:div, class: "flex items-center gap-2 mb-2") do
524
- concat('<svg class="w-4 h-4 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/></svg>'.html_safe)
525
- concat(content_tag(:span, "User", class: "text-sm font-medium text-slate-700"))
526
- end)
527
- concat(content_tag(:div, simple_format(h(user_msg.respond_to?(:content) ? user_msg.content.to_s : user_msg.to_s)), class: "prose prose-sm max-w-none"))
528
- end)
529
- end
530
-
531
- if asst_msg
532
- concat(content_tag(:div, class: "rounded-lg border p-4 bg-emerald-50 border-emerald-200") do
533
- concat(content_tag(:div, class: "flex items-center gap-2 mb-2") do
534
- concat('<svg class="w-4 h-4 text-emerald-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z"/></svg>'.html_safe)
535
- concat(content_tag(:span, "Assistant", class: "text-sm font-medium text-slate-700"))
536
- end)
537
- concat(content_tag(:div, simple_format(h(asst_msg.respond_to?(:content) ? asst_msg.content.to_s : asst_msg.to_s)), class: "prose prose-sm max-w-none"))
538
- end)
539
- end
540
-
541
- concat(content_tag(:p, "No messages found", class: "text-slate-400 italic text-sm")) unless user_msg || asst_msg
542
- end
334
+ # A bare `object.is_a?(ActiveRecord::Base)` crashes with `NameError` in a
335
+ # host without ActiveRecord loaded (this gem's own reference DB-free
336
+ # configuration among them) -- not masking, since it isn't inside a
337
+ # rescue, just a plain 500. Same idiom already used by
338
+ # `AdminSuite::UI::ShowFormatterRegistry` (`if defined?(ActiveRecord::Base)`).
339
+ #
340
+ # @param object [Object]
341
+ # @return [Boolean]
342
+ def active_record_base?(object)
343
+ defined?(ActiveRecord::Base) && object.is_a?(ActiveRecord::Base)
543
344
  end
544
345
 
545
346
  def auto_admin_suite_path_for(item)
546
- return nil unless item.is_a?(ActiveRecord::Base)
547
-
548
- ensure_admin_resources_loaded_for!(item.class)
347
+ return nil unless active_record_base?(item)
549
348
 
550
- resource = Admin::Base::Resource.registered_resources.find { |r| r.model_class == item.class }
349
+ resource = admin_suite_resource_for(item.class)
551
350
  return nil unless resource&.portal_name && resource.respond_to?(:resource_name_plural)
552
351
 
553
352
  resource_path(portal: resource.portal_name, resource_name: resource.resource_name_plural, id: item.to_param)
@@ -555,16 +354,30 @@ module AdminSuite
555
354
  nil
556
355
  end
557
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
+
558
376
  def ensure_admin_resources_loaded_for!(model_class)
559
377
  already_loaded = Admin::Base::Resource.registered_resources.any? { |r| r.model_class == model_class }
560
378
  return if already_loaded
561
379
 
562
- Array(AdminSuite.config.resource_globs).flat_map { |g| Dir[g] }.uniq.each do |file|
563
- require file
564
- end
565
- rescue NameError
566
- require "admin/base/resource"
567
- retry
380
+ AdminSuite::DefinitionLoader.load!(:resources)
568
381
  end
569
382
 
570
383
  # ---- show page sections / associations ----
@@ -573,7 +386,7 @@ module AdminSuite
573
386
  # `/internal/developer`. This is intentionally "UI heavy".
574
387
 
575
388
  def render_show_section(resource, section, position = :main)
576
- is_association = section.association.present? && !resource.public_send(section.association).is_a?(ActiveRecord::Base) rescue false
389
+ is_association = section.association.present? && !active_record_base?(resource.public_send(section.association)) rescue false
577
390
 
578
391
  content_tag(:div, class: "bg-white rounded-xl border border-slate-200 overflow-hidden") do
579
392
  header_padding = position == :sidebar ? "px-4 py-2.5" : "px-6 py-3"
@@ -585,7 +398,7 @@ module AdminSuite
585
398
 
586
399
  if section.association.present?
587
400
  assoc = resource.public_send(section.association) rescue nil
588
- if assoc && !assoc.is_a?(ActiveRecord::Base)
401
+ if assoc && !active_record_base?(assoc)
589
402
  count = assoc.count rescue 0
590
403
  color_class = count > 0 ? "bg-indigo-100 text-indigo-700" : "bg-slate-200 text-slate-600"
591
404
  concat(content_tag(:span, number_with_delimiter(count), class: "text-xs font-semibold px-2 py-0.5 rounded-full #{color_class}"))
@@ -601,11 +414,15 @@ module AdminSuite
601
414
 
602
415
  concat(content_tag(:div, class: content_padding) do
603
416
  if section.render.present?
604
- render_custom_section(resource, section.render)
417
+ render_custom_section(resource, section.render, section.options || {})
605
418
  elsif section.association.present?
606
419
  render_association_section(resource, section)
607
420
  elsif section.fields.any?
608
- 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
609
426
  else
610
427
  content_tag(:p, "No content", class: "text-slate-400 italic text-sm")
611
428
  end
@@ -613,11 +430,13 @@ module AdminSuite
613
430
  end
614
431
  end
615
432
 
616
- def render_sidebar_fields(resource, fields)
433
+ def render_sidebar_fields(resource, fields, hide_blank: false)
617
434
  content_tag(:div, class: "space-y-3") do
618
435
  fields.each do |field_name|
619
436
  value = resource.public_send(field_name) rescue nil
620
- 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)
621
440
  concat(render_sidebar_attachment(value))
622
441
  else
623
442
  concat(content_tag(:div, class: "flex justify-between items-start gap-2") do
@@ -665,9 +484,18 @@ module AdminSuite
665
484
  end
666
485
  end
667
486
 
668
- def render_main_fields(resource, fields)
487
+ def render_main_fields(resource, fields, hide_blank: false)
669
488
  content_tag(:dl, class: "space-y-6") do
670
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
+
671
499
  concat(content_tag(:div) do
672
500
  concat(content_tag(:dt, field_name.to_s.humanize, class: "text-sm font-medium text-slate-500 mb-2"))
673
501
  concat(content_tag(:dd, class: "text-sm text-slate-900") { format_show_value(resource, field_name) })
@@ -676,12 +504,44 @@ module AdminSuite
676
504
  end
677
505
  end
678
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
+
679
539
  # ---- association rendering ----
680
540
  def render_association_section(resource, section)
681
541
  associated = resource.public_send(section.association) rescue nil
682
542
  return content_tag(:p, "None found", class: "text-slate-400 italic text-sm") if associated.nil?
683
543
 
684
- is_single = !associated.respond_to?(:to_a) || associated.is_a?(ActiveRecord::Base)
544
+ is_single = !associated.respond_to?(:to_a) || active_record_base?(associated)
685
545
  return render_association_card_single(associated, section) if is_single
686
546
 
687
547
  items = associated
@@ -711,62 +571,12 @@ module AdminSuite
711
571
  else
712
572
  concat(render_association_list(items, section))
713
573
  end
714
- concat(render_association_pagination(pagy)) if pagy
574
+ concat(render("admin_suite/shared/pagination", pagy: pagy, page_param: association_page_param(section))) if pagy
715
575
  end
716
576
  end
717
577
 
718
578
  def association_page_param(section) = "#{section.association}_page"
719
579
 
720
- def render_association_pagination(pagy)
721
- content_tag(:div, class: "-mx-6 border-t border-slate-200 bg-slate-50/50 px-6 py-3") do
722
- content_tag(:nav, class: "flex items-center justify-between", "aria-label" => "Pagination") do
723
- concat(pagy_prev_link(pagy))
724
- concat(pagy_page_links(pagy))
725
- concat(pagy_next_link(pagy))
726
- end
727
- end
728
- end
729
-
730
- def pagy_prev_link(pagy)
731
- if pagy.prev
732
- link_to("Prev", pagy_url_for(pagy, pagy.prev),
733
- class: "px-3 py-1.5 text-sm font-medium text-slate-700 bg-white border border-slate-300 rounded-lg hover:bg-slate-50 transition-colors")
734
- else
735
- content_tag(:span, "Prev",
736
- class: "px-3 py-1.5 text-sm font-medium text-slate-400 bg-slate-100 border border-slate-200 rounded-lg cursor-not-allowed")
737
- end
738
- end
739
-
740
- def pagy_next_link(pagy)
741
- if pagy.next
742
- link_to("Next", pagy_url_for(pagy, pagy.next),
743
- class: "px-3 py-1.5 text-sm font-medium text-slate-700 bg-white border border-slate-300 rounded-lg hover:bg-slate-50 transition-colors")
744
- else
745
- content_tag(:span, "Next",
746
- class: "px-3 py-1.5 text-sm font-medium text-slate-400 bg-slate-100 border border-slate-200 rounded-lg cursor-not-allowed")
747
- end
748
- end
749
-
750
- def pagy_page_links(pagy)
751
- content_tag(:div, class: "flex items-center gap-1") do
752
- pagy.series.each { |item| concat(render_pagy_series_item(pagy, item)) }
753
- end
754
- end
755
-
756
- def render_pagy_series_item(pagy, item)
757
- case item
758
- when Integer
759
- link_to(item, pagy_url_for(pagy, item),
760
- class: "px-2.5 py-1 text-sm font-medium text-slate-700 bg-white border border-slate-300 rounded hover:bg-slate-50 transition-colors")
761
- when String
762
- content_tag(:span, item, class: "px-2.5 py-1 text-sm font-semibold text-white bg-indigo-600 border border-indigo-600 rounded")
763
- when :gap
764
- content_tag(:span, "…", class: "px-2 text-sm text-slate-400")
765
- else
766
- ""
767
- end
768
- end
769
-
770
580
  def render_association_card_single(item, section)
771
581
  link_path = build_association_link(item, section)
772
582
 
@@ -901,10 +711,55 @@ module AdminSuite
901
711
  when true, false then value ? "Yes" : "No"
902
712
  when Time, DateTime then value.strftime("%b %d, %H:%M")
903
713
  when Date then value.strftime("%b %d, %Y")
904
- when ActiveRecord::Base then item_display_title(value)
905
- else value.to_s.truncate(50)
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)
723
+ else
724
+ # A `when ActiveRecord::Base` clause would evaluate that constant
725
+ # reference unconditionally (crashing in a host without ActiveRecord
726
+ # loaded), so this branch is checked explicitly via the predicate
727
+ # instead of folded into the `case`.
728
+ return render_association_value(value) if active_record_base?(value)
729
+
730
+ value.to_s.truncate(50)
731
+ end
732
+ end
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
+ "—"
906
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
+ "—"
907
761
  end
762
+ private :render_association_value
908
763
 
909
764
  def item_display_title(item)
910
765
  return item.name if item.respond_to?(:name) && item.name.present?
@@ -988,58 +843,6 @@ module AdminSuite
988
843
  end
989
844
  end
990
845
 
991
- # ---- form fields ----
992
- def render_form_field(f, field, resource)
993
- return if field.if_condition.present? && !field.if_condition.call(resource)
994
- return if field.unless_condition.present? && field.unless_condition.call(resource)
995
-
996
- capture do
997
- concat(content_tag(:div, class: "form-group") do
998
- concat(f.label(field.name, class: "form-label") do
999
- concat(field.label)
1000
- concat(content_tag(:span, " *", class: "text-red-500")) if field.required
1001
- end)
1002
-
1003
- field_class = "form-input w-full"
1004
- field_class += " border-red-500" if resource.errors[field.name].any?
1005
-
1006
- field_html = case field.type
1007
- when :textarea then f.text_area(field.name, class: field_class, rows: field.rows || 4, placeholder: field.placeholder, readonly: field.readonly)
1008
- when :url then f.url_field(field.name, class: field_class, placeholder: field.placeholder, readonly: field.readonly)
1009
- when :email then f.email_field(field.name, class: field_class, placeholder: field.placeholder, readonly: field.readonly)
1010
- when :number then f.number_field(field.name, class: field_class, placeholder: field.placeholder, readonly: field.readonly)
1011
- when :toggle then render_toggle_field(f, field, resource)
1012
- when :label
1013
- label_value = resource.public_send(field.name) rescue nil
1014
- render_label_badge(label_value, color: field.label_color, size: field.label_size, record: resource)
1015
- when :select
1016
- collection = field.collection.is_a?(Proc) ? field.collection.call : field.collection
1017
- f.select(field.name, collection, { include_blank: true }, class: field_class, disabled: field.readonly)
1018
- when :searchable_select then render_searchable_select(f, field, resource)
1019
- when :multi_select, :tags then render_multi_select(f, field, resource)
1020
- when :image, :attachment then render_file_upload(f, field, resource)
1021
- when :trix, :rich_text then f.rich_text_area(field.name, class: "prose max-w-none")
1022
- when :markdown
1023
- f.text_area(field.name, class: "#{field_class} font-mono", rows: field.rows || 12, data: { controller: "admin-suite--markdown-editor" }, placeholder: field.placeholder)
1024
- when :file then f.file_field(field.name, class: "form-input-file", accept: field.accept)
1025
- when :datetime then f.datetime_local_field(field.name, class: field_class, readonly: field.readonly)
1026
- when :date then f.date_field(field.name, class: field_class, readonly: field.readonly)
1027
- when :time then f.time_field(field.name, class: field_class, readonly: field.readonly)
1028
- when :json
1029
- render("admin_suite/shared/json_editor_field", f: f, field: field, resource: resource)
1030
- when :code then render_code_editor(f, field, resource)
1031
- else
1032
- f.text_field(field.name, class: field_class, placeholder: field.placeholder, readonly: field.readonly)
1033
- end
1034
-
1035
- concat(field_html)
1036
-
1037
- concat(content_tag(:p, field.help, class: "mt-1 text-sm text-slate-500")) if field.help.present?
1038
- concat(content_tag(:p, resource.errors[field.name].first, class: "mt-1 text-sm text-red-600")) if resource.errors[field.name].any?
1039
- end)
1040
- end
1041
- end
1042
-
1043
846
  def render_toggle_field(_f, field, resource)
1044
847
  checked = !!resource.public_send(field.name)
1045
848
  param_key = resource.class.model_name.param_key
@@ -1065,10 +868,48 @@ module AdminSuite
1065
868
  end
1066
869
  end
1067
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
+
1068
905
  def render_searchable_select(_f, field, resource)
1069
906
  param_key = resource.class.model_name.param_key
1070
907
  current_value = resource.public_send(field.name)
1071
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
1072
913
 
1073
914
  options_json = if collection.is_a?(Array)
1074
915
  collection.map { |opt| opt.is_a?(Array) ? { value: opt[1], label: opt[0] } : { value: opt, label: opt.to_s.humanize } }.to_json
@@ -1098,7 +939,7 @@ module AdminSuite
1098
939
  controller: "admin-suite--searchable-select",
1099
940
  "admin-suite--searchable-select-options-value": options_json,
1100
941
  "admin-suite--searchable-select-creatable-value": field.create_url.present?,
1101
- "admin-suite--searchable-select-search-url-value": collection.is_a?(String) ? collection : "",
942
+ "admin-suite--searchable-select-search-url-value": search_url,
1102
943
  "admin-suite--searchable-select-create-url-value": field.create_url.to_s
1103
944
  },
1104
945
  class: "relative") do