admin_suite 0.3.1 → 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 (54) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +82 -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/_stat.html.erb +10 -0
  11. data/app/views/admin_suite/resources/index.html.erb +7 -77
  12. data/app/views/admin_suite/shared/_pagination.html.erb +74 -0
  13. data/app/views/admin_suite/shared/_sidebar.html.erb +1 -1
  14. data/app/views/layouts/admin_suite/application.html.erb +5 -3
  15. data/lib/admin/base/action_executor.rb +19 -51
  16. data/lib/admin/base/resource.rb +53 -14
  17. data/lib/admin_suite/configuration.rb +28 -3
  18. data/lib/admin_suite/definition_loader.rb +194 -0
  19. data/lib/admin_suite/deprecation.rb +48 -0
  20. data/lib/admin_suite/engine.rb +55 -76
  21. data/lib/admin_suite/host_autoload_policy.rb +132 -0
  22. data/lib/admin_suite/legacy_custom_renderer_procs.rb +29 -0
  23. data/lib/admin_suite/portal_definition.rb +11 -0
  24. data/lib/admin_suite/renderer.rb +133 -0
  25. data/lib/admin_suite/renderer_registry.rb +70 -0
  26. data/lib/admin_suite/renderers/code_renderer.rb +15 -0
  27. data/lib/admin_suite/renderers/json_renderer.rb +15 -0
  28. data/lib/admin_suite/renderers/key_value_renderer.rb +39 -0
  29. data/lib/admin_suite/renderers/legacy_gleania.rb +230 -0
  30. data/lib/admin_suite/renderers/table_from_renderer.rb +22 -0
  31. data/lib/admin_suite/section_definition.rb +42 -0
  32. data/lib/admin_suite/ui/field_renderer_registry.rb +31 -4
  33. data/lib/admin_suite/ui/form_field_renderer.rb +1 -7
  34. data/lib/admin_suite/ui/show_formatter_registry.rb +9 -0
  35. data/lib/admin_suite/ui/show_value_formatter.rb +7 -3
  36. data/lib/admin_suite/version.rb +1 -1
  37. data/lib/admin_suite.rb +37 -0
  38. data/lib/generators/admin_suite/install/templates/admin_suite.rb +0 -4
  39. data/test/controllers/resources_controller_test.rb +76 -1
  40. data/test/integration/layout_assets_test.rb +112 -0
  41. data/test/integration/navigation_sections_test.rb +41 -0
  42. data/test/integration/pagination_and_stats_test.rb +152 -0
  43. data/test/lib/action_executor_redirect_test.rb +41 -0
  44. data/test/lib/builtin_renderers_test.rb +202 -0
  45. data/test/lib/definition_loader_test.rb +264 -0
  46. data/test/lib/engine_defaults_test.rb +39 -0
  47. data/test/lib/form_field_renderer_test.rb +64 -0
  48. data/test/lib/legacy_renderer_deprecation_test.rb +35 -0
  49. data/test/lib/renderer_test.rb +221 -0
  50. data/test/lib/resource_exportable_deprecation_test.rb +39 -0
  51. data/test/lib/show_value_formatter_test.rb +88 -0
  52. data/test/lib/zeitwerk_integration_test.rb +28 -64
  53. data/test/test_helper.rb +77 -0
  54. metadata +28 -1
@@ -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,70 @@
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
+ end
69
+ end
70
+ 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,230 @@
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 DELETED in
11
+ # 0.5.0 (Phase 2b). Gleania is expected to migrate to host-side renderer
12
+ # classes under `app/admin/renderers/*.rb` before then.
13
+ #
14
+ # The renderer bodies below are intentionally byte-equivalent to the
15
+ # BaseHelper methods they replace, other than `resource` -> `record` and
16
+ # routing BaseHelper method calls (`render_json_block`, `simple_format`,
17
+ # `concat`) through `view.` — this class is not `included` into the view,
18
+ # so those calls can't resolve as bare method sends the way they did
19
+ # inside the helper module. Do not "improve" this markup: a byte-for-byte
20
+ # move keeps gleania's Assistant/AI portal pages pixel-identical, and
21
+ # makes the 0.5.0 deletion a clean removal.
22
+ module LegacyGleania
23
+ extend AdminSuite::Deprecation
24
+
25
+ DEPRECATION_MESSAGE_FORMAT =
26
+ "AdminSuite: the :%<key>s renderer is deprecated and will be removed in 0.5.0. " \
27
+ "Move it to app/admin/renderers in your app."
28
+
29
+ class << self
30
+ # Fires the deprecation sink at most once per `key` per process.
31
+ # `warn_once_sink` and `reset_deprecation_notices!` come from
32
+ # `AdminSuite::Deprecation`, extended above.
33
+ #
34
+ # @param key [Symbol]
35
+ # @return [void]
36
+ def warn_once(key)
37
+ super(key, format(DEPRECATION_MESSAGE_FORMAT, key: key))
38
+ end
39
+ end
40
+
41
+ # Verbatim from `BaseHelper#render_prompt_template`.
42
+ class PromptTemplateRenderer < Renderer
43
+ def render
44
+ LegacyGleania.warn_once(:prompt_template_preview)
45
+
46
+ template = record.respond_to?(:prompt_template) ? record.prompt_template : nil
47
+ return content_tag(:p, "No template defined", class: "text-slate-500 italic") if template.blank?
48
+
49
+ highlighted_template = h(template).gsub(/\{\{(\w+)\}\}/) do
50
+ "<span class=\"text-amber-400 bg-amber-900/30 px-1 rounded\">{{#{$1}}}</span>"
51
+ end
52
+
53
+ content_tag(:div, class: "relative group") do
54
+ view.concat(content_tag(:div, class: "absolute top-2 right-2 flex items-center gap-2") do
55
+ view.concat(content_tag(:span, "TEMPLATE", class: "text-xs font-medium text-slate-400 uppercase tracking-wider"))
56
+ view.concat(content_tag(:button,
57
+ '<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,
58
+ type: "button",
59
+ class: "p-1 text-slate-400 hover:text-slate-600 opacity-0 group-hover:opacity-100 transition-opacity",
60
+ data: { controller: "admin-suite--clipboard", action: "click->admin-suite--clipboard#copy", "admin-suite--clipboard-text-value": template },
61
+ title: "Copy to clipboard"))
62
+ end)
63
+
64
+ 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
65
+ highlighted_template.html_safe
66
+ end)
67
+
68
+ variables = template.scan(/\{\{(\w+)\}\}/).flatten.uniq
69
+ if variables.any?
70
+ view.concat(content_tag(:div, class: "mt-3 pt-3 border-t border-slate-700") do
71
+ view.concat(content_tag(:span, "Variables: ", class: "text-sm text-slate-400"))
72
+ view.concat(content_tag(:div, class: "inline-flex flex-wrap gap-1 mt-1") do
73
+ variables.each do |var|
74
+ view.concat(content_tag(:code, "{{#{var}}}", class: "text-xs px-2 py-0.5 bg-amber-900/30 text-amber-400 rounded"))
75
+ end
76
+ end)
77
+ end)
78
+ end
79
+ end
80
+ end
81
+ end
82
+
83
+ # Verbatim from `BaseHelper#render_messages_preview`.
84
+ class MessagesPreviewRenderer < Renderer
85
+ def render
86
+ LegacyGleania.warn_once(:messages_preview)
87
+
88
+ messages = record.respond_to?(:messages) ? record.messages : []
89
+ if messages.respond_to?(:chronological)
90
+ messages = messages.chronological
91
+ end
92
+ messages = messages.limit(50) if messages.respond_to?(:limit)
93
+ messages = Array.wrap(messages)
94
+
95
+ return content_tag(:p, "No messages", class: "text-slate-500 italic") if messages.blank?
96
+
97
+ content_tag(:div, class: "space-y-4 max-h-[600px] overflow-y-auto -mx-6 -mb-6 p-6 pt-0") do
98
+ messages.each_with_index do |msg, idx|
99
+ if msg.respond_to?(:role)
100
+ role = msg.role
101
+ content = msg.content
102
+ created_at = msg.respond_to?(:created_at) ? msg.created_at : nil
103
+ else
104
+ role = msg["role"] || msg[:role] || "unknown"
105
+ content = msg["content"] || msg[:content] || ""
106
+ created_at = msg["created_at"] || msg[:created_at]
107
+ end
108
+
109
+ role_class = case role.to_s
110
+ when "user" then "bg-blue-50 border-blue-200"
111
+ when "assistant" then "bg-emerald-50 border-emerald-200"
112
+ when "tool" then "bg-amber-50 border-amber-200"
113
+ when "system" then "bg-slate-50 border-slate-200"
114
+ else "bg-slate-50 border-slate-200"
115
+ end
116
+
117
+ role_icon = case role.to_s
118
+ when "user"
119
+ '<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
120
+ when "assistant"
121
+ '<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
122
+ when "tool"
123
+ '<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
124
+ else
125
+ '<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
126
+ end
127
+
128
+ view.concat(content_tag(:div, class: "rounded-lg border p-4 #{role_class}") do
129
+ view.concat(content_tag(:div, class: "flex items-center justify-between mb-3") do
130
+ view.concat(content_tag(:div, class: "flex items-center gap-2") do
131
+ view.concat(role_icon)
132
+ view.concat(content_tag(:span, role.to_s.capitalize, class: "text-sm font-medium text-slate-700"))
133
+ end)
134
+ view.concat(content_tag(:div, class: "flex items-center gap-2 text-xs text-slate-400") do
135
+ view.concat(content_tag(:span, created_at.strftime("%H:%M:%S"))) if created_at.respond_to?(:strftime)
136
+ view.concat(content_tag(:span, "##{idx + 1}"))
137
+ end)
138
+ end)
139
+
140
+ content_str = content.to_s
141
+ if role.to_s == "tool" && content_str.start_with?("{", "[")
142
+ begin
143
+ parsed = JSON.parse(content_str)
144
+ view.concat(view.render_json_block(parsed))
145
+ rescue JSON::ParserError
146
+ view.concat(content_tag(:div, view.simple_format(h(content_str)), class: "prose prose-sm max-w-none"))
147
+ end
148
+ else
149
+ view.concat(content_tag(:div, view.simple_format(h(content_str)), class: "prose prose-sm max-w-none"))
150
+ end
151
+ end)
152
+ end
153
+ end
154
+ end
155
+ end
156
+
157
+ # Verbatim from `BaseHelper#render_tool_args_preview`.
158
+ class ToolArgsRenderer < Renderer
159
+ def render
160
+ LegacyGleania.warn_once(:tool_args_preview)
161
+
162
+ args = record.respond_to?(:args) ? record.args : (record.respond_to?(:arguments) ? record.arguments : {})
163
+ result = record.respond_to?(:result) ? record.result : nil
164
+ error = record.respond_to?(:error) ? record.error : nil
165
+
166
+ content_tag(:div, class: "space-y-6") do
167
+ view.concat(content_tag(:div) do
168
+ view.concat(content_tag(:h4, "Arguments", class: "text-sm font-medium text-slate-500 mb-2"))
169
+ if args.present? && args != {}
170
+ view.concat(view.render_json_block(args))
171
+ else
172
+ view.concat(content_tag(:p, "No arguments", class: "text-slate-400 italic text-sm"))
173
+ end
174
+ end)
175
+
176
+ if result.present? && result != {}
177
+ view.concat(content_tag(:div, class: "pt-4 border-t border-slate-200") do
178
+ view.concat(content_tag(:h4, "Result", class: "text-sm font-medium text-slate-500 mb-2"))
179
+ view.concat(view.render_json_block(result))
180
+ end)
181
+ end
182
+
183
+ if error.present?
184
+ view.concat(content_tag(:div, class: "pt-4 border-t border-slate-200") do
185
+ view.concat(content_tag(:h4, "Error", class: "text-sm font-medium text-red-500 mb-2"))
186
+ view.concat(content_tag(:div, class: "bg-red-50 border border-red-200 rounded-lg p-4") do
187
+ content_tag(:pre, h(error.to_s), class: "text-sm text-red-700 whitespace-pre-wrap font-mono")
188
+ end)
189
+ end)
190
+ end
191
+ end
192
+ end
193
+ end
194
+
195
+ # Verbatim from `BaseHelper#render_turn_messages_preview`.
196
+ class TurnMessagesRenderer < Renderer
197
+ def render
198
+ LegacyGleania.warn_once(:turn_messages_preview)
199
+
200
+ user_msg = record.respond_to?(:user_message) ? record.user_message : nil
201
+ asst_msg = record.respond_to?(:assistant_message) ? record.assistant_message : nil
202
+
203
+ content_tag(:div, class: "space-y-4") do
204
+ if user_msg
205
+ view.concat(content_tag(:div, class: "rounded-lg border p-4 bg-blue-50 border-blue-200") do
206
+ view.concat(content_tag(:div, class: "flex items-center gap-2 mb-2") do
207
+ 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)
208
+ view.concat(content_tag(:span, "User", class: "text-sm font-medium text-slate-700"))
209
+ end)
210
+ 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"))
211
+ end)
212
+ end
213
+
214
+ if asst_msg
215
+ view.concat(content_tag(:div, class: "rounded-lg border p-4 bg-emerald-50 border-emerald-200") do
216
+ view.concat(content_tag(:div, class: "flex items-center gap-2 mb-2") do
217
+ 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)
218
+ view.concat(content_tag(:span, "Assistant", class: "text-sm font-medium text-slate-700"))
219
+ end)
220
+ 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"))
221
+ end)
222
+ end
223
+
224
+ view.concat(content_tag(:p, "No messages found", class: "text-slate-400 italic text-sm")) unless user_msg || asst_msg
225
+ end
226
+ end
227
+ end
228
+ end
229
+ end
230
+ 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
@@ -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
 
@@ -4,12 +4,9 @@ require "admin_suite/ui/field_renderer_registry"
4
4
 
5
5
  module AdminSuite
6
6
  module UI
7
- # Overrides `render_form_field` to use a registry of field renderers,
8
- # while leaving the legacy implementation available via `super`.
7
+ # Implements `render_form_field` using a registry of field renderers.
9
8
  module FormFieldRenderer
10
9
  def render_form_field(f, field, resource)
11
- return super unless defined?(AdminSuite::UI::FieldRendererRegistry)
12
-
13
10
  return if field.if_condition.present? && !field.if_condition.call(resource)
14
11
  return if field.unless_condition.present? && field.unless_condition.call(resource)
15
12
 
@@ -33,9 +30,6 @@ module AdminSuite
33
30
  field_class: field_class
34
31
  )
35
32
 
36
- # If the registry doesn't know how to render, fall back to legacy behavior.
37
- return super if field_html.nil?
38
-
39
33
  concat(field_html)
40
34
 
41
35
  concat(content_tag(:p, field.help, class: "mt-1 text-sm text-slate-500")) if field.help.present?
@@ -102,6 +102,15 @@ AdminSuite::UI::ShowFormatterRegistry.register_class(Float) do |value, view, _re
102
102
  view.content_tag(:span, view.number_with_delimiter(value), class: "font-mono")
103
103
  end
104
104
 
105
+ AdminSuite::UI::ShowFormatterRegistry.register_class(BigDecimal) do |value, view, _record, _field|
106
+ # `.to_f` avoids number_with_delimiter rendering exponential notation
107
+ # (e.g. "0.98765e4") for BigDecimal input, at the cost of Float precision
108
+ # for values beyond Float's exact range (e.g. sub-cent monetary sums).
109
+ # That's an accepted display-only tradeoff here — don't "fix" this by
110
+ # dropping the conversion, or exponential notation comes back.
111
+ view.content_tag(:span, view.number_with_delimiter(value.to_f), class: "font-mono")
112
+ end
113
+
105
114
  AdminSuite::UI::ShowFormatterRegistry.register_default do |value, view, _record, field_name|
106
115
  value_str = value.to_s
107
116