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
@@ -0,0 +1,133 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AdminSuite
4
+ # Base class for panel renderers.
5
+ #
6
+ # Host apps subclass this in `app/admin/renderers/*.rb` and reference it
7
+ # from a show panel:
8
+ #
9
+ # panel :costs, title: "Provider Costs", render: :provider_costs
10
+ # # => Admin::Renderers::ProviderCostsRenderer
11
+ #
12
+ # Subclasses implement #render and may use the primitives below rather than
13
+ # hand-building markup, so panels stay visually consistent with the rest of
14
+ # the admin UI.
15
+ class Renderer
16
+ attr_reader :record, :view, :options
17
+
18
+ # @param record [Object] the resource being rendered
19
+ # @param view [ActionView::Base] the calling view/helper context
20
+ # @param options [Hash] leftover panel DSL options (e.g. `source:`,
21
+ # `columns:`, `empty:`, `language:`) forwarded from `ShowSectionDefinition#options`.
22
+ # Defaults to `{}` so Task 3's two-arg construction keeps working.
23
+ def initialize(record, view, options = {})
24
+ @record = record
25
+ @view = view
26
+ @options = options || {}
27
+ end
28
+
29
+ # @return [String] HTML-safe markup for the panel body
30
+ def render
31
+ raise NotImplementedError, "#{self.class.name} must implement #render"
32
+ end
33
+
34
+ private
35
+
36
+ def content_tag(...) = view.content_tag(...)
37
+ def safe_join(...) = view.safe_join(...)
38
+
39
+ # `ERB::Util#h` (aliased from `html_escape`) is a private instance method
40
+ # on every Rails view context, including `ActionView::Base` itself — not
41
+ # just in `ActionView::TestCase`. `view.h(...)` would raise a private-method
42
+ # `NoMethodError` on every call site, so this goes through `#send` instead.
43
+ def h(...) = view.send(:h, ...)
44
+
45
+ # Resolves the panel's `source:` option: a Proc called with the record
46
+ # (or with no args, if it takes none), a Symbol/String sent to the
47
+ # record, or a literal value. Falls back to `default` when no `source:`
48
+ # option was given.
49
+ def source_value(default = nil)
50
+ source = options[:source]
51
+ case source
52
+ when Proc then source.arity.zero? ? source.call : source.call(record)
53
+ when Symbol, String then record.public_send(source)
54
+ when nil then default
55
+ else source
56
+ end
57
+ end
58
+
59
+ # Pretty-printed JSON in a copyable dark block.
60
+ #
61
+ # `BaseHelper#render_json_block` takes only the data (no title), so a
62
+ # title, when given, is rendered as a small heading above the block
63
+ # rather than threaded into the helper.
64
+ def json_block(value, title: nil)
65
+ block = view.render_json_block(value)
66
+ return block if title.blank?
67
+
68
+ safe_join([
69
+ content_tag(:h4, title, class: "text-sm font-medium text-slate-500 mb-2"),
70
+ block
71
+ ])
72
+ end
73
+
74
+ # Syntax-highlighted text block.
75
+ def code_block(text, language: nil)
76
+ view.render_text_block(text, language)
77
+ end
78
+
79
+ # @param pairs [Array<Array(String, Object)>] label/value pairs
80
+ def key_value_list(pairs)
81
+ rows = pairs.map do |label, value|
82
+ content_tag(:div, class: "flex justify-between gap-4 py-2 border-b border-slate-100 last:border-0") do
83
+ safe_join([
84
+ content_tag(:span, label.to_s, class: "text-sm text-slate-500"),
85
+ content_tag(:span, value.to_s, class: "text-sm text-slate-900 text-right")
86
+ ])
87
+ end
88
+ end
89
+ content_tag(:div, safe_join(rows))
90
+ end
91
+
92
+ # @param rows [Array<Hash>] row hashes keyed by the column names. `columns`
93
+ # is always an array of Symbols (see callers), but row hashes commonly
94
+ # are not — Rails deserializes JSONB columns to String-keyed Hashes, so
95
+ # rows are symbolized once here rather than requiring every caller (and
96
+ # every host renderer calling this primitive directly) to remember to
97
+ # do it themselves. A per-cell `row.key?(c) ? row[c] : row[c.to_s]`
98
+ # would avoid the copy but re-checks both key forms on every cell of
99
+ # every row; symbolizing each row once up front is both simpler and
100
+ # cheaper for any table with more than one column.
101
+ # @param columns [Array<Symbol>] column order
102
+ # @param empty [String, nil] message when rows are blank
103
+ def data_table(rows, columns:, empty: nil)
104
+ return empty_state(empty || "None found.") if rows.blank?
105
+
106
+ rows = rows.map { |row| row.is_a?(Hash) ? row.symbolize_keys : row }
107
+
108
+ header = content_tag(:tr, safe_join(columns.map { |c|
109
+ content_tag(:th, c.to_s.humanize, class: "text-left text-xs font-medium text-slate-400 uppercase tracking-wider pb-2")
110
+ }))
111
+
112
+ body = rows.map do |row|
113
+ content_tag(:tr, safe_join(columns.map { |c|
114
+ content_tag(:td, row[c].to_s, class: "py-2 text-sm text-slate-900 border-t border-slate-100")
115
+ }))
116
+ end
117
+
118
+ content_tag(:div, class: "overflow-x-auto") do
119
+ content_tag(:table, safe_join([ content_tag(:thead, header), content_tag(:tbody, safe_join(body)) ]), class: "w-full")
120
+ end
121
+ end
122
+
123
+ # `BaseHelper#render_label_badge` takes `color:` as a keyword argument
124
+ # (not positional), so it is passed through as one here.
125
+ def badge(text, color: :slate)
126
+ view.render_label_badge(text, color: color)
127
+ end
128
+
129
+ def empty_state(message)
130
+ content_tag(:p, message, class: "text-slate-500 italic text-sm")
131
+ end
132
+ end
133
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AdminSuite
4
+ # Maps panel `render:` keys to Renderer classes.
5
+ #
6
+ # Two independent stores, so a host following the deprecation advice
7
+ # printed for a legacy renderer (or simply overriding a built-in) actually
8
+ # takes effect:
9
+ #
10
+ # - `register` / `lookup` — explicit registrations: a host initializer (or
11
+ # a spec) calling `RendererRegistry.register(:key, SomeClass)`.
12
+ # - `register_default` / `lookup_default` — the gem's own boot-time
13
+ # registrations (the four built-ins, their two aliases, and the four
14
+ # deprecated Gleania renderers — see `lib/admin_suite.rb`).
15
+ #
16
+ # `AdminSuite::BaseHelper#render_custom_section` checks `lookup`, then a
17
+ # host `Admin::Renderers::<Key>Renderer` class, then `lookup_default` —
18
+ # explicit beats host-class beats gem-default. See that method for the
19
+ # full precedence chain (legacy `config.custom_renderers` procs come
20
+ # first, ahead of all of this).
21
+ module RendererRegistry
22
+ @registry = {}
23
+ @defaults = {}
24
+
25
+ class << self
26
+ # Explicit registration (host apps, initializers, specs). Takes
27
+ # precedence over everything but a legacy `config.custom_renderers`
28
+ # proc.
29
+ def register(key, klass)
30
+ @registry[key.to_sym] = klass
31
+ end
32
+
33
+ # Gem boot-time registration. Only consulted after a host's own
34
+ # explicit registration and its `Admin::Renderers::<Key>Renderer`
35
+ # class have both been checked and found nothing.
36
+ def register_default(key, klass)
37
+ @defaults[key.to_sym] = klass
38
+ end
39
+
40
+ # @return [Class, nil] the explicit registration for `key`, if any
41
+ def lookup(key)
42
+ @registry[key.to_sym]
43
+ end
44
+
45
+ # @return [Class, nil] the gem-default registration for `key`, if any
46
+ def lookup_default(key)
47
+ @defaults[key.to_sym]
48
+ end
49
+
50
+ # Every key known to either store (explicit ∪ default).
51
+ #
52
+ # @return [Array<Symbol>]
53
+ def registered
54
+ (@defaults.keys | @registry.keys)
55
+ end
56
+
57
+ # Test-support only. Removes a single *explicit* registration.
58
+ #
59
+ # There is intentionally no bulk `reset!`, and this never touches the
60
+ # default store: AdminSuite's own built-in renderers register
61
+ # themselves as defaults at require time, once, for the life of the
62
+ # process. Specs that register scratch/probe renderers via `register`
63
+ # should call `unregister` for exactly the key(s) they added, in a
64
+ # teardown.
65
+ def unregister(key)
66
+ @registry.delete(key.to_sym)
67
+ end
68
+
69
+ # Test-support only. Removes a single *default* registration.
70
+ #
71
+ # Exists solely so a spec that seeds a scratch key into the default
72
+ # store (e.g. to test host-class-vs-default precedence) can clean up
73
+ # after itself. AdminSuite's own built-in defaults are registered once
74
+ # at require time and are never expected to be removed in a running
75
+ # process -- do not call this outside a test teardown.
76
+ def unregister_default(key)
77
+ @defaults.delete(key.to_sym)
78
+ end
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AdminSuite
4
+ module Renderers
5
+ # Syntax-highlighted code panel.
6
+ class CodeRenderer < Renderer
7
+ def render
8
+ text = source_value(record.respond_to?(:code) ? record.code : record.to_s)
9
+ return empty_state(options[:empty] || "Nothing to display.") if text.blank?
10
+
11
+ code_block(text.to_s, language: options[:language])
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AdminSuite
4
+ module Renderers
5
+ # Pretty-printed JSON panel. `source:` defaults to the record's attributes.
6
+ class JsonRenderer < Renderer
7
+ def render
8
+ value = source_value(record.respond_to?(:attributes) ? record.attributes : record)
9
+ return empty_state(options[:empty] || "Nothing to display.") if value.blank?
10
+
11
+ json_block(value)
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AdminSuite
4
+ module Renderers
5
+ # Label/value list panel. `source:` returns a Hash or an Array of
6
+ # [key, value] pairs.
7
+ #
8
+ # Unlike `data_table`, the `key_value_list` primitive has no `empty:`
9
+ # option (see Task 4 report for why) — emptiness is handled here instead.
10
+ class KeyValueRenderer < Renderer
11
+ def render
12
+ value = source_value({})
13
+ pairs =
14
+ case value
15
+ when Hash then value.to_a
16
+ when Array then value
17
+ when nil then []
18
+ else
19
+ # A Hash is handled above; anything else that coerces via #to_a
20
+ # (AR relations, Sets, ...) is supported the same way `Array(value)`
21
+ # supported it before this guard existed — only reject things
22
+ # that genuinely aren't enumerable.
23
+ unless value.respond_to?(:to_a)
24
+ raise ArgumentError, "key_value expects a Hash or an Array of pairs, got #{value.class}"
25
+ end
26
+ value.to_a
27
+ end
28
+ return empty_state(options[:empty] || "Nothing to display.") if pairs.blank?
29
+
30
+ unless pairs.all? { |pair| pair.is_a?(Array) && pair.size == 2 }
31
+ raise ArgumentError, "key_value expects a Hash or an Array of [key, value] pairs, " \
32
+ "got an Array containing non-pair elements"
33
+ end
34
+
35
+ key_value_list(pairs.map { |k, v| [ k.to_s.humanize, v ] })
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,232 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AdminSuite
4
+ module Renderers
5
+ # The four Gleania-specific LLM chat-transcript renderers, moved verbatim
6
+ # out of `AdminSuite::BaseHelper` (role-coloured message bubbles, tool-call
7
+ # panels, prompt-template mustache highlighting).
8
+ #
9
+ # These are deprecated: they still work in 0.4.0 (logging a warning once
10
+ # per key per process the first time each is used), and are targeted for
11
+ # DELETION in 0.6.0 (moved from the originally-planned 0.5.0/Phase 2b,
12
+ # pending both the gleania and trust_growth host migrations). Gleania is
13
+ # expected to migrate to host-side renderer classes under
14
+ # `app/admin/renderers/*.rb` before then.
15
+ #
16
+ # The renderer bodies below are intentionally byte-equivalent to the
17
+ # BaseHelper methods they replace, other than `resource` -> `record` and
18
+ # routing BaseHelper method calls (`render_json_block`, `simple_format`,
19
+ # `concat`) through `view.` — this class is not `included` into the view,
20
+ # so those calls can't resolve as bare method sends the way they did
21
+ # inside the helper module. Do not "improve" this markup: a byte-for-byte
22
+ # move keeps gleania's Assistant/AI portal pages pixel-identical, and
23
+ # makes the eventual 0.6.0 deletion a clean removal.
24
+ module LegacyGleania
25
+ extend AdminSuite::Deprecation
26
+
27
+ DEPRECATION_MESSAGE_FORMAT =
28
+ "AdminSuite: the :%<key>s renderer is deprecated and will be removed in 0.6.0. " \
29
+ "Move it to app/admin/renderers in your app."
30
+
31
+ class << self
32
+ # Fires the deprecation sink at most once per `key` per process.
33
+ # `warn_once_sink` and `reset_deprecation_notices!` come from
34
+ # `AdminSuite::Deprecation`, extended above.
35
+ #
36
+ # @param key [Symbol]
37
+ # @return [void]
38
+ def warn_once(key)
39
+ super(key, format(DEPRECATION_MESSAGE_FORMAT, key: key))
40
+ end
41
+ end
42
+
43
+ # Verbatim from `BaseHelper#render_prompt_template`.
44
+ class PromptTemplateRenderer < Renderer
45
+ def render
46
+ LegacyGleania.warn_once(:prompt_template_preview)
47
+
48
+ template = record.respond_to?(:prompt_template) ? record.prompt_template : nil
49
+ return content_tag(:p, "No template defined", class: "text-slate-500 italic") if template.blank?
50
+
51
+ highlighted_template = h(template).gsub(/\{\{(\w+)\}\}/) do
52
+ "<span class=\"text-amber-400 bg-amber-900/30 px-1 rounded\">{{#{$1}}}</span>"
53
+ end
54
+
55
+ content_tag(:div, class: "relative group") do
56
+ view.concat(content_tag(:div, class: "absolute top-2 right-2 flex items-center gap-2") do
57
+ view.concat(content_tag(:span, "TEMPLATE", class: "text-xs font-medium text-slate-400 uppercase tracking-wider"))
58
+ view.concat(content_tag(:button,
59
+ '<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,
60
+ type: "button",
61
+ class: "p-1 text-slate-400 hover:text-slate-600 opacity-0 group-hover:opacity-100 transition-opacity",
62
+ data: { controller: "admin-suite--clipboard", action: "click->admin-suite--clipboard#copy", "admin-suite--clipboard-text-value": template },
63
+ title: "Copy to clipboard"))
64
+ end)
65
+
66
+ view.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
67
+ highlighted_template.html_safe
68
+ end)
69
+
70
+ variables = template.scan(/\{\{(\w+)\}\}/).flatten.uniq
71
+ if variables.any?
72
+ view.concat(content_tag(:div, class: "mt-3 pt-3 border-t border-slate-700") do
73
+ view.concat(content_tag(:span, "Variables: ", class: "text-sm text-slate-400"))
74
+ view.concat(content_tag(:div, class: "inline-flex flex-wrap gap-1 mt-1") do
75
+ variables.each do |var|
76
+ view.concat(content_tag(:code, "{{#{var}}}", class: "text-xs px-2 py-0.5 bg-amber-900/30 text-amber-400 rounded"))
77
+ end
78
+ end)
79
+ end)
80
+ end
81
+ end
82
+ end
83
+ end
84
+
85
+ # Verbatim from `BaseHelper#render_messages_preview`.
86
+ class MessagesPreviewRenderer < Renderer
87
+ def render
88
+ LegacyGleania.warn_once(:messages_preview)
89
+
90
+ messages = record.respond_to?(:messages) ? record.messages : []
91
+ if messages.respond_to?(:chronological)
92
+ messages = messages.chronological
93
+ end
94
+ messages = messages.limit(50) if messages.respond_to?(:limit)
95
+ messages = Array.wrap(messages)
96
+
97
+ return content_tag(:p, "No messages", class: "text-slate-500 italic") if messages.blank?
98
+
99
+ content_tag(:div, class: "space-y-4 max-h-[600px] overflow-y-auto -mx-6 -mb-6 p-6 pt-0") do
100
+ messages.each_with_index do |msg, idx|
101
+ if msg.respond_to?(:role)
102
+ role = msg.role
103
+ content = msg.content
104
+ created_at = msg.respond_to?(:created_at) ? msg.created_at : nil
105
+ else
106
+ role = msg["role"] || msg[:role] || "unknown"
107
+ content = msg["content"] || msg[:content] || ""
108
+ created_at = msg["created_at"] || msg[:created_at]
109
+ end
110
+
111
+ role_class = case role.to_s
112
+ when "user" then "bg-blue-50 border-blue-200"
113
+ when "assistant" then "bg-emerald-50 border-emerald-200"
114
+ when "tool" then "bg-amber-50 border-amber-200"
115
+ when "system" then "bg-slate-50 border-slate-200"
116
+ else "bg-slate-50 border-slate-200"
117
+ end
118
+
119
+ role_icon = case role.to_s
120
+ when "user"
121
+ '<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
122
+ when "assistant"
123
+ '<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
124
+ when "tool"
125
+ '<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
126
+ else
127
+ '<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
128
+ end
129
+
130
+ view.concat(content_tag(:div, class: "rounded-lg border p-4 #{role_class}") do
131
+ view.concat(content_tag(:div, class: "flex items-center justify-between mb-3") do
132
+ view.concat(content_tag(:div, class: "flex items-center gap-2") do
133
+ view.concat(role_icon)
134
+ view.concat(content_tag(:span, role.to_s.capitalize, class: "text-sm font-medium text-slate-700"))
135
+ end)
136
+ view.concat(content_tag(:div, class: "flex items-center gap-2 text-xs text-slate-400") do
137
+ view.concat(content_tag(:span, created_at.strftime("%H:%M:%S"))) if created_at.respond_to?(:strftime)
138
+ view.concat(content_tag(:span, "##{idx + 1}"))
139
+ end)
140
+ end)
141
+
142
+ content_str = content.to_s
143
+ if role.to_s == "tool" && content_str.start_with?("{", "[")
144
+ begin
145
+ parsed = JSON.parse(content_str)
146
+ view.concat(view.render_json_block(parsed))
147
+ rescue JSON::ParserError
148
+ view.concat(content_tag(:div, view.simple_format(h(content_str)), class: "prose prose-sm max-w-none"))
149
+ end
150
+ else
151
+ view.concat(content_tag(:div, view.simple_format(h(content_str)), class: "prose prose-sm max-w-none"))
152
+ end
153
+ end)
154
+ end
155
+ end
156
+ end
157
+ end
158
+
159
+ # Verbatim from `BaseHelper#render_tool_args_preview`.
160
+ class ToolArgsRenderer < Renderer
161
+ def render
162
+ LegacyGleania.warn_once(:tool_args_preview)
163
+
164
+ args = record.respond_to?(:args) ? record.args : (record.respond_to?(:arguments) ? record.arguments : {})
165
+ result = record.respond_to?(:result) ? record.result : nil
166
+ error = record.respond_to?(:error) ? record.error : nil
167
+
168
+ content_tag(:div, class: "space-y-6") do
169
+ view.concat(content_tag(:div) do
170
+ view.concat(content_tag(:h4, "Arguments", class: "text-sm font-medium text-slate-500 mb-2"))
171
+ if args.present? && args != {}
172
+ view.concat(view.render_json_block(args))
173
+ else
174
+ view.concat(content_tag(:p, "No arguments", class: "text-slate-400 italic text-sm"))
175
+ end
176
+ end)
177
+
178
+ if result.present? && result != {}
179
+ view.concat(content_tag(:div, class: "pt-4 border-t border-slate-200") do
180
+ view.concat(content_tag(:h4, "Result", class: "text-sm font-medium text-slate-500 mb-2"))
181
+ view.concat(view.render_json_block(result))
182
+ end)
183
+ end
184
+
185
+ if error.present?
186
+ view.concat(content_tag(:div, class: "pt-4 border-t border-slate-200") do
187
+ view.concat(content_tag(:h4, "Error", class: "text-sm font-medium text-red-500 mb-2"))
188
+ view.concat(content_tag(:div, class: "bg-red-50 border border-red-200 rounded-lg p-4") do
189
+ content_tag(:pre, h(error.to_s), class: "text-sm text-red-700 whitespace-pre-wrap font-mono")
190
+ end)
191
+ end)
192
+ end
193
+ end
194
+ end
195
+ end
196
+
197
+ # Verbatim from `BaseHelper#render_turn_messages_preview`.
198
+ class TurnMessagesRenderer < Renderer
199
+ def render
200
+ LegacyGleania.warn_once(:turn_messages_preview)
201
+
202
+ user_msg = record.respond_to?(:user_message) ? record.user_message : nil
203
+ asst_msg = record.respond_to?(:assistant_message) ? record.assistant_message : nil
204
+
205
+ content_tag(:div, class: "space-y-4") do
206
+ if user_msg
207
+ view.concat(content_tag(:div, class: "rounded-lg border p-4 bg-blue-50 border-blue-200") do
208
+ view.concat(content_tag(:div, class: "flex items-center gap-2 mb-2") do
209
+ view.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)
210
+ view.concat(content_tag(:span, "User", class: "text-sm font-medium text-slate-700"))
211
+ end)
212
+ view.concat(content_tag(:div, view.simple_format(h(user_msg.respond_to?(:content) ? user_msg.content.to_s : user_msg.to_s)), class: "prose prose-sm max-w-none"))
213
+ end)
214
+ end
215
+
216
+ if asst_msg
217
+ view.concat(content_tag(:div, class: "rounded-lg border p-4 bg-emerald-50 border-emerald-200") do
218
+ view.concat(content_tag(:div, class: "flex items-center gap-2 mb-2") do
219
+ view.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)
220
+ view.concat(content_tag(:span, "Assistant", class: "text-sm font-medium text-slate-700"))
221
+ end)
222
+ view.concat(content_tag(:div, view.simple_format(h(asst_msg.respond_to?(:content) ? asst_msg.content.to_s : asst_msg.to_s)), class: "prose prose-sm max-w-none"))
223
+ end)
224
+ end
225
+
226
+ view.concat(content_tag(:p, "No messages found", class: "text-slate-400 italic text-sm")) unless user_msg || asst_msg
227
+ end
228
+ end
229
+ end
230
+ end
231
+ end
232
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AdminSuite
4
+ module Renderers
5
+ # Table panel over an Array of Hashes. Columns default to the first row's keys.
6
+ class TableFromRenderer < Renderer
7
+ def render
8
+ rows = source_value([]) || []
9
+ # A Hash source is the contract violation worth naming; other
10
+ # Enumerables (AR relations, Sets, ...) coerce cleanly via #to_a and
11
+ # were supported before this guard existed — don't narrow that away.
12
+ rows = rows.to_a if !rows.is_a?(Array) && !rows.is_a?(Hash) && rows.respond_to?(:to_a)
13
+ unless rows.is_a?(Array)
14
+ raise ArgumentError, "table_from expects an Array of Hashes, got #{rows.class}"
15
+ end
16
+
17
+ columns = options[:columns].presence || rows.first&.keys || []
18
+ data_table(rows, columns: columns.map(&:to_sym), empty: options[:empty])
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AdminSuite
4
+ # A navigation section within a portal. Sections group resources in the
5
+ # sidebar; declaring one lets a portal control its label, icon and order
6
+ # instead of accepting the humanized key and alphabetical placement.
7
+ class SectionDefinition
8
+ attr_reader :key
9
+
10
+ def initialize(key)
11
+ @key = key.to_sym
12
+ @label = nil
13
+ @icon = nil
14
+ @order = nil
15
+ @description = nil
16
+ end
17
+
18
+ def label(value = nil)
19
+ @label = value if value.present?
20
+ @label
21
+ end
22
+
23
+ def icon(value = nil)
24
+ @icon = value if value.present?
25
+ @icon
26
+ end
27
+
28
+ def order(value = nil)
29
+ @order = value unless value.nil?
30
+ @order
31
+ end
32
+
33
+ def description(value = nil)
34
+ @description = value if value.present?
35
+ @description
36
+ end
37
+
38
+ def to_nav_meta
39
+ { label: @label, icon: @icon, order: @order, description: @description }.compact
40
+ end
41
+ end
42
+ end
@@ -48,6 +48,12 @@ module AdminSuite
48
48
  panel(:health, title, span: span, **options.merge(status: status, metrics: metrics, block: block))
49
49
  end
50
50
 
51
+ # `type:` selects the Chart.js chart type: `:bar` (default), `:line`,
52
+ # `:area` (a line chart rendered with fill), or `:doughnut`. It flows
53
+ # through `**options` untouched by this method -- the `_chart.html.erb`
54
+ # partial is what validates it, falling back to `:bar` with a logged
55
+ # warning for anything else, so a host typo degrades rather than
56
+ # breaking the dashboard.
51
57
  def chart_panel(title, data: nil, span: nil, **options, &block)
52
58
  data_proc = data.is_a?(Proc) ? data : (block_given? ? block : nil)
53
59
  panel(:chart, title, span: span, **options.merge(data: data_proc || data))
@@ -12,10 +12,17 @@ module AdminSuite
12
12
  handlers[type.to_sym] = block
13
13
  end
14
14
 
15
- def render(type, view:, f:, field:, resource:, field_class:)
16
- handler = handlers[type.to_sym]
17
- return nil unless handler
15
+ # Fallback for unregistered field types: a plain text input. Keeps the
16
+ # registry total so callers never need a secondary rendering path.
17
+ def default_handler
18
+ @default_handler ||= ->(_view, f, field, _resource, field_class) {
19
+ f.text_field(field.name, class: field_class, placeholder: field.placeholder,
20
+ readonly: field.readonly)
21
+ }
22
+ end
18
23
 
24
+ def render(type, view:, f:, field:, resource:, field_class:)
25
+ handler = handlers[type.to_sym] || default_handler
19
26
  handler.call(view, f, field, resource, field_class)
20
27
  end
21
28
  end
@@ -86,7 +93,27 @@ AdminSuite::UI::FieldRendererRegistry.register(:rich_text) do |_view, f, field,
86
93
  f.rich_text_area(field.name, class: "prose max-w-none")
87
94
  end
88
95
 
89
- AdminSuite::UI::FieldRendererRegistry.register(:markdown) do |_view, f, field, resource, field_class|
96
+ AdminSuite::UI::FieldRendererRegistry.register(:markdown) do |view, f, field, resource, field_class|
97
+ # Load the vendored EasyMDE assets only on pages that actually render a
98
+ # markdown field, via a dedicated content_for hook consumed by the layout
99
+ # (see app/views/layouts/admin_suite/application.html.erb). Guarded so
100
+ # multiple markdown fields on one form don't emit the tags twice. This
101
+ # relies on Rails rendering the full view (and any partials/forms it
102
+ # includes) before the layout is rendered, so content_for set here is
103
+ # available by the time the layout yields it.
104
+ unless view.content_for?(:easymde_assets)
105
+ view.content_for(:easymde_assets) do
106
+ # "vendor/easymde.min" (not bare "easymde.min"): Propshaft resolves
107
+ # assets by path relative to a load-path root, and app/assets/vendor
108
+ # lives *under* the already-registered app/assets root, so its files
109
+ # are found at "vendor/easymde.min.{js,css}".
110
+ view.safe_join([
111
+ view.stylesheet_link_tag("vendor/easymde.min", "data-turbo-track": "reload"),
112
+ view.javascript_include_tag("vendor/easymde.min", "data-turbo-track": "reload")
113
+ ])
114
+ end
115
+ end
116
+
90
117
  f.text_area(field.name, class: "#{field_class} font-mono", rows: field.rows || 12, data: { controller: "admin-suite--markdown-editor" }, placeholder: field.placeholder)
91
118
  end
92
119