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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +88 -0
- data/app/assets/vendor/easymde.min.css +7 -0
- data/app/assets/vendor/easymde.min.js +7 -0
- data/app/controllers/admin_suite/application_controller.rb +26 -47
- data/app/controllers/admin_suite/portals_controller.rb +2 -2
- data/app/controllers/admin_suite/resources_controller.rb +8 -1
- data/app/helpers/admin_suite/base_helper.rb +49 -385
- data/app/javascript/controllers/admin_suite/markdown_editor_controller.js +21 -2
- data/app/views/admin_suite/panels/_chart.html.erb +3 -2
- data/app/views/admin_suite/panels/_stat.html.erb +10 -0
- data/app/views/admin_suite/resources/index.html.erb +7 -77
- data/app/views/admin_suite/shared/_pagination.html.erb +74 -0
- data/app/views/admin_suite/shared/_sidebar.html.erb +1 -1
- data/app/views/layouts/admin_suite/application.html.erb +5 -3
- data/lib/admin/base/action_executor.rb +19 -51
- data/lib/admin/base/resource.rb +53 -14
- data/lib/admin_suite/configuration.rb +28 -3
- data/lib/admin_suite/definition_loader.rb +194 -0
- data/lib/admin_suite/deprecation.rb +48 -0
- data/lib/admin_suite/engine.rb +55 -76
- data/lib/admin_suite/host_autoload_policy.rb +132 -0
- data/lib/admin_suite/legacy_custom_renderer_procs.rb +29 -0
- data/lib/admin_suite/portal_definition.rb +11 -0
- data/lib/admin_suite/renderer.rb +133 -0
- data/lib/admin_suite/renderer_registry.rb +70 -0
- data/lib/admin_suite/renderers/code_renderer.rb +15 -0
- data/lib/admin_suite/renderers/json_renderer.rb +15 -0
- data/lib/admin_suite/renderers/key_value_renderer.rb +39 -0
- data/lib/admin_suite/renderers/legacy_gleania.rb +230 -0
- data/lib/admin_suite/renderers/table_from_renderer.rb +22 -0
- data/lib/admin_suite/section_definition.rb +42 -0
- data/lib/admin_suite/ui/field_renderer_registry.rb +31 -4
- data/lib/admin_suite/ui/form_field_renderer.rb +1 -7
- data/lib/admin_suite/ui/show_formatter_registry.rb +9 -0
- data/lib/admin_suite/ui/show_value_formatter.rb +7 -3
- data/lib/admin_suite/version.rb +1 -1
- data/lib/admin_suite.rb +37 -0
- data/lib/generators/admin_suite/install/templates/admin_suite.rb +0 -4
- data/test/controllers/resources_controller_test.rb +76 -1
- data/test/integration/dashboard_test.rb +40 -0
- data/test/integration/layout_assets_test.rb +112 -0
- data/test/integration/navigation_sections_test.rb +41 -0
- data/test/integration/pagination_and_stats_test.rb +152 -0
- data/test/lib/action_executor_redirect_test.rb +41 -0
- data/test/lib/builtin_renderers_test.rb +202 -0
- data/test/lib/definition_loader_test.rb +264 -0
- data/test/lib/engine_defaults_test.rb +39 -0
- data/test/lib/form_field_renderer_test.rb +64 -0
- data/test/lib/legacy_renderer_deprecation_test.rb +35 -0
- data/test/lib/renderer_test.rb +221 -0
- data/test/lib/resource_exportable_deprecation_test.rb +39 -0
- data/test/lib/show_value_formatter_test.rb +88 -0
- data/test/lib/zeitwerk_integration_test.rb +28 -64
- data/test/test_helper.rb +77 -0
- 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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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 |
|
|
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
|
-
#
|
|
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
|
|