admin_suite 0.3.0 → 0.4.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 (56) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +88 -0
  3. data/app/assets/vendor/easymde.min.css +7 -0
  4. data/app/assets/vendor/easymde.min.js +7 -0
  5. data/app/controllers/admin_suite/application_controller.rb +26 -47
  6. data/app/controllers/admin_suite/portals_controller.rb +2 -2
  7. data/app/controllers/admin_suite/resources_controller.rb +8 -1
  8. data/app/helpers/admin_suite/base_helper.rb +49 -385
  9. data/app/javascript/controllers/admin_suite/markdown_editor_controller.js +21 -2
  10. data/app/views/admin_suite/panels/_chart.html.erb +3 -2
  11. data/app/views/admin_suite/panels/_stat.html.erb +10 -0
  12. data/app/views/admin_suite/resources/index.html.erb +7 -77
  13. data/app/views/admin_suite/shared/_pagination.html.erb +74 -0
  14. data/app/views/admin_suite/shared/_sidebar.html.erb +1 -1
  15. data/app/views/layouts/admin_suite/application.html.erb +5 -3
  16. data/lib/admin/base/action_executor.rb +19 -51
  17. data/lib/admin/base/resource.rb +53 -14
  18. data/lib/admin_suite/configuration.rb +28 -3
  19. data/lib/admin_suite/definition_loader.rb +194 -0
  20. data/lib/admin_suite/deprecation.rb +48 -0
  21. data/lib/admin_suite/engine.rb +55 -76
  22. data/lib/admin_suite/host_autoload_policy.rb +132 -0
  23. data/lib/admin_suite/legacy_custom_renderer_procs.rb +29 -0
  24. data/lib/admin_suite/portal_definition.rb +11 -0
  25. data/lib/admin_suite/renderer.rb +133 -0
  26. data/lib/admin_suite/renderer_registry.rb +70 -0
  27. data/lib/admin_suite/renderers/code_renderer.rb +15 -0
  28. data/lib/admin_suite/renderers/json_renderer.rb +15 -0
  29. data/lib/admin_suite/renderers/key_value_renderer.rb +39 -0
  30. data/lib/admin_suite/renderers/legacy_gleania.rb +230 -0
  31. data/lib/admin_suite/renderers/table_from_renderer.rb +22 -0
  32. data/lib/admin_suite/section_definition.rb +42 -0
  33. data/lib/admin_suite/ui/field_renderer_registry.rb +31 -4
  34. data/lib/admin_suite/ui/form_field_renderer.rb +1 -7
  35. data/lib/admin_suite/ui/show_formatter_registry.rb +9 -0
  36. data/lib/admin_suite/ui/show_value_formatter.rb +7 -3
  37. data/lib/admin_suite/version.rb +1 -1
  38. data/lib/admin_suite.rb +37 -0
  39. data/lib/generators/admin_suite/install/templates/admin_suite.rb +0 -4
  40. data/test/controllers/resources_controller_test.rb +76 -1
  41. data/test/integration/dashboard_test.rb +40 -0
  42. data/test/integration/layout_assets_test.rb +112 -0
  43. data/test/integration/navigation_sections_test.rb +41 -0
  44. data/test/integration/pagination_and_stats_test.rb +152 -0
  45. data/test/lib/action_executor_redirect_test.rb +41 -0
  46. data/test/lib/builtin_renderers_test.rb +202 -0
  47. data/test/lib/definition_loader_test.rb +264 -0
  48. data/test/lib/engine_defaults_test.rb +39 -0
  49. data/test/lib/form_field_renderer_test.rb +64 -0
  50. data/test/lib/legacy_renderer_deprecation_test.rb +35 -0
  51. data/test/lib/renderer_test.rb +221 -0
  52. data/test/lib/resource_exportable_deprecation_test.rb +39 -0
  53. data/test/lib/show_value_formatter_test.rb +88 -0
  54. data/test/lib/zeitwerk_integration_test.rb +28 -64
  55. data/test/test_helper.rb +77 -0
  56. metadata +28 -1
@@ -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
  #
@@ -148,79 +145,6 @@ module AdminSuite
148
145
  end
149
146
  end
150
147
 
151
- # Formats a value for display on show pages
152
- #
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
221
- end
222
- end
223
-
224
148
  def render_attachment_preview(attachment)
225
149
  return content_tag(:span, "—", class: "text-slate-400") unless attachment.attached?
226
150
 
@@ -341,209 +265,50 @@ module AdminSuite
341
265
  nil
342
266
  end
343
267
 
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
268
+ def render_custom_section(resource, render_type, options = {})
269
+ key = render_type.to_sym
365
270
 
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?
370
-
371
- highlighted_template = h(template).gsub(/\{\{(\w+)\}\}/) do
372
- "<span class=\"text-amber-400 bg-amber-900/30 px-1 rounded\">{{#{$1}}}</span>"
271
+ legacy_proc = AdminSuite.config.custom_renderers[key]
272
+ if legacy_proc
273
+ AdminSuite::LegacyCustomRendererProcs.warn_once(key)
274
+ return legacy_proc.call(resource, self)
373
275
  end
374
276
 
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
277
+ # Precedence, after the legacy proc above (unchanged, Task 3 has tests
278
+ # pinning that): a host's explicit registration, then a host renderer
279
+ # class, then the gem's own defaults (built-ins + the deprecated four
280
+ # Gleania renderers). Checking the gem defaults last means a host that
281
+ # defines `Admin::Renderers::<Key>Renderer` -- exactly the migration
282
+ # path the deprecation warnings recommend -- actually takes effect
283
+ # instead of being silently shadowed by the gem's boot-time
284
+ # registrations (see `RendererRegistry`).
285
+ klass = AdminSuite::RendererRegistry.lookup(key) ||
286
+ host_renderer_class(key) ||
287
+ AdminSuite::RendererRegistry.lookup_default(key)
288
+ return klass.new(resource, self, options).render if klass
403
289
 
404
- def render_json_preview(resource)
405
- data = resource.respond_to?(:data) ? resource.data : resource.attributes
406
- render_json_block(data)
290
+ content_tag(:p, "Unknown render type: #{render_type}", class: "text-slate-500 italic")
407
291
  end
408
292
 
409
- def render_code_preview(resource)
410
- code = resource.respond_to?(:code) ? resource.code : resource.to_s
411
- render_text_block(code, :ruby)
293
+ # Resolves `Admin::Renderers::<Key>Renderer` in the host app, if defined.
294
+ def host_renderer_class(key)
295
+ "Admin::Renderers::#{key.to_s.camelize}Renderer".safe_constantize
412
296
  end
413
297
 
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
481
- end
482
-
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
514
- end
515
-
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
298
+ # A bare `object.is_a?(ActiveRecord::Base)` crashes with `NameError` in a
299
+ # host without ActiveRecord loaded (this gem's own reference DB-free
300
+ # configuration among them) -- not masking, since it isn't inside a
301
+ # rescue, just a plain 500. Same idiom already used by
302
+ # `AdminSuite::UI::ShowFormatterRegistry` (`if defined?(ActiveRecord::Base)`).
303
+ #
304
+ # @param object [Object]
305
+ # @return [Boolean]
306
+ def active_record_base?(object)
307
+ defined?(ActiveRecord::Base) && object.is_a?(ActiveRecord::Base)
543
308
  end
544
309
 
545
310
  def auto_admin_suite_path_for(item)
546
- return nil unless item.is_a?(ActiveRecord::Base)
311
+ return nil unless active_record_base?(item)
547
312
 
548
313
  ensure_admin_resources_loaded_for!(item.class)
549
314
 
@@ -559,12 +324,7 @@ module AdminSuite
559
324
  already_loaded = Admin::Base::Resource.registered_resources.any? { |r| r.model_class == model_class }
560
325
  return if already_loaded
561
326
 
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
327
+ AdminSuite::DefinitionLoader.load!(:resources)
568
328
  end
569
329
 
570
330
  # ---- show page sections / associations ----
@@ -573,7 +333,7 @@ module AdminSuite
573
333
  # `/internal/developer`. This is intentionally "UI heavy".
574
334
 
575
335
  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
336
+ is_association = section.association.present? && !active_record_base?(resource.public_send(section.association)) rescue false
577
337
 
578
338
  content_tag(:div, class: "bg-white rounded-xl border border-slate-200 overflow-hidden") do
579
339
  header_padding = position == :sidebar ? "px-4 py-2.5" : "px-6 py-3"
@@ -585,7 +345,7 @@ module AdminSuite
585
345
 
586
346
  if section.association.present?
587
347
  assoc = resource.public_send(section.association) rescue nil
588
- if assoc && !assoc.is_a?(ActiveRecord::Base)
348
+ if assoc && !active_record_base?(assoc)
589
349
  count = assoc.count rescue 0
590
350
  color_class = count > 0 ? "bg-indigo-100 text-indigo-700" : "bg-slate-200 text-slate-600"
591
351
  concat(content_tag(:span, number_with_delimiter(count), class: "text-xs font-semibold px-2 py-0.5 rounded-full #{color_class}"))
@@ -601,7 +361,7 @@ module AdminSuite
601
361
 
602
362
  concat(content_tag(:div, class: content_padding) do
603
363
  if section.render.present?
604
- render_custom_section(resource, section.render)
364
+ render_custom_section(resource, section.render, section.options || {})
605
365
  elsif section.association.present?
606
366
  render_association_section(resource, section)
607
367
  elsif section.fields.any?
@@ -681,7 +441,7 @@ module AdminSuite
681
441
  associated = resource.public_send(section.association) rescue nil
682
442
  return content_tag(:p, "None found", class: "text-slate-400 italic text-sm") if associated.nil?
683
443
 
684
- is_single = !associated.respond_to?(:to_a) || associated.is_a?(ActiveRecord::Base)
444
+ is_single = !associated.respond_to?(:to_a) || active_record_base?(associated)
685
445
  return render_association_card_single(associated, section) if is_single
686
446
 
687
447
  items = associated
@@ -711,62 +471,12 @@ module AdminSuite
711
471
  else
712
472
  concat(render_association_list(items, section))
713
473
  end
714
- concat(render_association_pagination(pagy)) if pagy
474
+ concat(render("admin_suite/shared/pagination", pagy: pagy, page_param: association_page_param(section))) if pagy
715
475
  end
716
476
  end
717
477
 
718
478
  def association_page_param(section) = "#{section.association}_page"
719
479
 
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
480
  def render_association_card_single(item, section)
771
481
  link_path = build_association_link(item, section)
772
482
 
@@ -901,8 +611,14 @@ module AdminSuite
901
611
  when true, false then value ? "Yes" : "No"
902
612
  when Time, DateTime then value.strftime("%b %d, %H:%M")
903
613
  when Date then value.strftime("%b %d, %Y")
904
- when ActiveRecord::Base then item_display_title(value)
905
- else value.to_s.truncate(50)
614
+ else
615
+ # A `when ActiveRecord::Base` clause would evaluate that constant
616
+ # reference unconditionally (crashing in a host without ActiveRecord
617
+ # loaded), so this branch is checked explicitly via the predicate
618
+ # instead of folded into the `case`.
619
+ return item_display_title(value) if active_record_base?(value)
620
+
621
+ value.to_s.truncate(50)
906
622
  end
907
623
  end
908
624
 
@@ -988,58 +704,6 @@ module AdminSuite
988
704
  end
989
705
  end
990
706
 
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
707
  def render_toggle_field(_f, field, resource)
1044
708
  checked = !!resource.public_send(field.name)
1045
709
  param_key = resource.class.model_name.param_key
@@ -4,18 +4,37 @@ import { Controller } from "@hotwired/stimulus"
4
4
  * Markdown Editor Controller (Admin Suite)
5
5
  *
6
6
  * Initializes EasyMDE on a textarea element for rich markdown editing.
7
- * EasyMDE is loaded globally via script tag in the admin layout.
7
+ * EasyMDE is loaded from the engine's vendored asset (see app/assets/vendor)
8
+ * only on pages that render a markdown field.
8
9
  */
10
+
11
+ // Bounded retry: ~50 attempts at 100ms each (~5s) before giving up. Guards
12
+ // against an unbounded poll if the EasyMDE asset fails to load (e.g. blocked
13
+ // by a CSP, 404, or a page that never actually included it).
14
+ const MAX_INIT_ATTEMPTS = 50
15
+ const INIT_RETRY_DELAY_MS = 100
16
+
9
17
  export default class extends Controller {
10
18
  static targets = ["textarea"]
11
19
 
12
20
  connect() {
21
+ this.initAttempts = 0
13
22
  this.initEditor()
14
23
  }
15
24
 
16
25
  initEditor() {
17
26
  if (typeof window.EasyMDE === "undefined") {
18
- setTimeout(() => this.initEditor(), 100)
27
+ this.initAttempts += 1
28
+ if (this.initAttempts >= MAX_INIT_ATTEMPTS) {
29
+ console.warn(
30
+ "admin-suite--markdown-editor: EasyMDE failed to load after " +
31
+ MAX_INIT_ATTEMPTS +
32
+ " attempts; falling back to a plain textarea."
33
+ )
34
+ return
35
+ }
36
+
37
+ setTimeout(() => this.initEditor(), INIT_RETRY_DELAY_MS)
19
38
  return
20
39
  }
21
40
 
@@ -29,7 +29,8 @@ end %>
29
29
  <% height_float = (value / max_value) * 100.0 %>
30
30
  <% height_float = 0.0 if height_float.nan? || height_float.infinite? %>
31
31
  <% height = height_float.round %>
32
- <div class="flex-1 flex flex-col items-center gap-1">
32
+ <% height = [height, 2].max if value.positive? %>
33
+ <div class="flex-1 h-full flex flex-col items-center justify-end">
33
34
  <div class="w-full rounded-t <%= bar_color %> transition-all" style="height: <%= height %>%"
34
35
  title="<%= "#{d[:label]}: #{d[:value]}" %>"></div>
35
36
  </div>
@@ -38,7 +39,7 @@ end %>
38
39
 
39
40
  <div class="flex gap-1 mt-2">
40
41
  <% data.each do |d| %>
41
- <div class="flex-1 text-center text-xs text-slate-400"><%= d[:label].to_s.first(3) %></div>
42
+ <div class="flex-1 text-center text-xs text-slate-400" title="<%= d[:label] %>"><%= d[:label] %></div>
42
43
  <% end %>
43
44
  </div>
44
45
  <% else %>
@@ -31,6 +31,16 @@
31
31
  border: "border border-violet-200",
32
32
  text: "text-violet-700",
33
33
  label: "text-violet-600" }
34
+ when :slate
35
+ { bg: "bg-white",
36
+ border: "border border-slate-200",
37
+ text: "text-slate-600",
38
+ label: "text-slate-500" }
39
+ when :indigo
40
+ { bg: "bg-gradient-to-br from-indigo-50 to-indigo-100",
41
+ border: "border border-indigo-200",
42
+ text: "text-indigo-700",
43
+ label: "text-indigo-600" }
34
44
  else
35
45
  { bg: "bg-white",
36
46
  border: "border border-slate-200",