admin_suite 0.1.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 +7 -0
- data/.gitignore +10 -0
- data/Gemfile +13 -0
- data/LICENSE.txt +22 -0
- data/README.md +7 -0
- data/Rakefile +11 -0
- data/app/assets/admin_suite.css +444 -0
- data/app/assets/admin_suite_tailwind.css +8 -0
- data/app/assets/builds/admin_suite_tailwind.css +8 -0
- data/app/assets/rouge.css +218 -0
- data/app/assets/tailwind/admin_suite.css +22 -0
- data/app/controllers/admin_suite/application_controller.rb +118 -0
- data/app/controllers/admin_suite/dashboard_controller.rb +258 -0
- data/app/controllers/admin_suite/docs_controller.rb +155 -0
- data/app/controllers/admin_suite/portals_controller.rb +22 -0
- data/app/controllers/admin_suite/resources_controller.rb +238 -0
- data/app/helpers/admin_suite/base_helper.rb +1199 -0
- data/app/helpers/admin_suite/icon_helper.rb +61 -0
- data/app/helpers/admin_suite/panels_helper.rb +52 -0
- data/app/helpers/admin_suite/resources_helper.rb +15 -0
- data/app/helpers/admin_suite/theme_helper.rb +99 -0
- data/app/javascript/controllers/admin_suite/click_actions_controller.js +73 -0
- data/app/javascript/controllers/admin_suite/clipboard_controller.js +57 -0
- data/app/javascript/controllers/admin_suite/code_editor_controller.js +45 -0
- data/app/javascript/controllers/admin_suite/file_upload_controller.js +238 -0
- data/app/javascript/controllers/admin_suite/json_editor_controller.js +62 -0
- data/app/javascript/controllers/admin_suite/live_filter_controller.js +71 -0
- data/app/javascript/controllers/admin_suite/markdown_editor_controller.js +67 -0
- data/app/javascript/controllers/admin_suite/searchable_select_controller.js +171 -0
- data/app/javascript/controllers/admin_suite/sidebar_controller.js +33 -0
- data/app/javascript/controllers/admin_suite/tag_select_controller.js +193 -0
- data/app/javascript/controllers/admin_suite/toggle_switch_controller.js +66 -0
- data/app/views/admin_suite/dashboard/index.html.erb +21 -0
- data/app/views/admin_suite/docs/index.html.erb +86 -0
- data/app/views/admin_suite/panels/_cards.html.erb +107 -0
- data/app/views/admin_suite/panels/_chart.html.erb +47 -0
- data/app/views/admin_suite/panels/_health.html.erb +44 -0
- data/app/views/admin_suite/panels/_recent.html.erb +56 -0
- data/app/views/admin_suite/panels/_stat.html.erb +64 -0
- data/app/views/admin_suite/panels/_table.html.erb +36 -0
- data/app/views/admin_suite/portals/show.html.erb +75 -0
- data/app/views/admin_suite/resources/_form.html.erb +32 -0
- data/app/views/admin_suite/resources/edit.html.erb +24 -0
- data/app/views/admin_suite/resources/index.html.erb +315 -0
- data/app/views/admin_suite/resources/new.html.erb +22 -0
- data/app/views/admin_suite/resources/show.html.erb +184 -0
- data/app/views/admin_suite/shared/_flash.html.erb +30 -0
- data/app/views/admin_suite/shared/_form.html.erb +60 -0
- data/app/views/admin_suite/shared/_json_editor_field.html.erb +52 -0
- data/app/views/admin_suite/shared/_sidebar.html.erb +94 -0
- data/app/views/admin_suite/shared/_toggle_cell.html.erb +34 -0
- data/app/views/admin_suite/shared/_topbar.html.erb +47 -0
- data/app/views/layouts/admin_suite/application.html.erb +79 -0
- data/lib/admin/base/action_executor.rb +155 -0
- data/lib/admin/base/action_handler.rb +31 -0
- data/lib/admin/base/filter_builder.rb +121 -0
- data/lib/admin/base/resource.rb +541 -0
- data/lib/admin_suite/configuration.rb +42 -0
- data/lib/admin_suite/engine.rb +101 -0
- data/lib/admin_suite/markdown_renderer.rb +115 -0
- data/lib/admin_suite/portal_definition.rb +64 -0
- data/lib/admin_suite/portal_registry.rb +32 -0
- data/lib/admin_suite/theme_palette.rb +36 -0
- data/lib/admin_suite/ui/dashboard_definition.rb +69 -0
- data/lib/admin_suite/ui/field_renderer_registry.rb +119 -0
- data/lib/admin_suite/ui/form_field_renderer.rb +48 -0
- data/lib/admin_suite/ui/show_formatter_registry.rb +120 -0
- data/lib/admin_suite/ui/show_value_formatter.rb +70 -0
- data/lib/admin_suite/version.rb +10 -0
- data/lib/admin_suite.rb +54 -0
- data/lib/generators/admin_suite/install/install_generator.rb +23 -0
- data/lib/generators/admin_suite/install/templates/admin_suite.rb +60 -0
- data/lib/generators/admin_suite/resource/resource_generator.rb +83 -0
- data/lib/generators/admin_suite/resource/templates/resource.rb.tt +47 -0
- data/lib/generators/admin_suite/scaffold/scaffold_generator.rb +28 -0
- data/lib/tasks/admin_suite_tailwind.rake +28 -0
- data/lib/tasks/admin_suite_test.rake +11 -0
- data/test/dummy/Gemfile +21 -0
- data/test/dummy/README.md +24 -0
- data/test/dummy/Rakefile +6 -0
- data/test/dummy/app/assets/stylesheets/application.css +10 -0
- data/test/dummy/app/controllers/application_controller.rb +4 -0
- data/test/dummy/app/helpers/application_helper.rb +2 -0
- data/test/dummy/app/models/application_record.rb +2 -0
- data/test/dummy/app/views/layouts/application.html.erb +28 -0
- data/test/dummy/app/views/pwa/manifest.json.erb +22 -0
- data/test/dummy/app/views/pwa/service-worker.js +26 -0
- data/test/dummy/bin/ci +6 -0
- data/test/dummy/bin/dev +2 -0
- data/test/dummy/bin/rails +4 -0
- data/test/dummy/bin/rake +4 -0
- data/test/dummy/bin/setup +35 -0
- data/test/dummy/config/application.rb +43 -0
- data/test/dummy/config/boot.rb +3 -0
- data/test/dummy/config/ci.rb +19 -0
- data/test/dummy/config/database.yml +31 -0
- data/test/dummy/config/environment.rb +5 -0
- data/test/dummy/config/environments/development.rb +57 -0
- data/test/dummy/config/environments/production.rb +67 -0
- data/test/dummy/config/environments/test.rb +42 -0
- data/test/dummy/config/initializers/assets.rb +7 -0
- data/test/dummy/config/initializers/content_security_policy.rb +29 -0
- data/test/dummy/config/initializers/filter_parameter_logging.rb +8 -0
- data/test/dummy/config/initializers/inflections.rb +16 -0
- data/test/dummy/config/locales/en.yml +31 -0
- data/test/dummy/config/puma.rb +39 -0
- data/test/dummy/config/routes.rb +16 -0
- data/test/dummy/config.ru +6 -0
- data/test/dummy/db/seeds.rb +9 -0
- data/test/dummy/log/test.log +441 -0
- data/test/dummy/public/400.html +135 -0
- data/test/dummy/public/404.html +135 -0
- data/test/dummy/public/406-unsupported-browser.html +135 -0
- data/test/dummy/public/422.html +135 -0
- data/test/dummy/public/500.html +135 -0
- data/test/dummy/public/icon.png +0 -0
- data/test/dummy/public/icon.svg +3 -0
- data/test/dummy/public/robots.txt +1 -0
- data/test/dummy/test/test_helper.rb +15 -0
- data/test/dummy/tmp/local_secret.txt +1 -0
- data/test/fixtures/docs/progress/PROGRESS_REPORT.md +6 -0
- data/test/integration/dashboard_test.rb +13 -0
- data/test/integration/docs_test.rb +46 -0
- data/test/integration/theme_test.rb +27 -0
- data/test/lib/markdown_renderer_test.rb +20 -0
- data/test/lib/theme_palette_test.rb +24 -0
- data/test/test_helper.rb +11 -0
- metadata +264 -0
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "redcarpet"
|
|
4
|
+
require "rouge"
|
|
5
|
+
require "rouge/plugins/redcarpet"
|
|
6
|
+
|
|
7
|
+
module AdminSuite
|
|
8
|
+
# MarkdownRenderer converts markdown text into safe HTML with syntax highlighting.
|
|
9
|
+
#
|
|
10
|
+
# Uses Redcarpet for markdown parsing, Rouge for syntax highlighting,
|
|
11
|
+
# and extracts a table of contents from headings.
|
|
12
|
+
class MarkdownRenderer
|
|
13
|
+
attr_reader :markdown
|
|
14
|
+
|
|
15
|
+
def initialize(markdown)
|
|
16
|
+
@markdown = markdown.to_s
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# @return [Hash{Symbol=>Object}]
|
|
20
|
+
def render
|
|
21
|
+
result = self.class.render_with_toc(markdown)
|
|
22
|
+
|
|
23
|
+
{
|
|
24
|
+
html: result[:html],
|
|
25
|
+
toc: result[:toc],
|
|
26
|
+
reading_time_minutes: reading_time_minutes
|
|
27
|
+
}
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# @param text [String]
|
|
31
|
+
# @return [ActiveSupport::SafeBuffer]
|
|
32
|
+
def self.render(text)
|
|
33
|
+
renderer = HtmlRenderer.new
|
|
34
|
+
md = Redcarpet::Markdown.new(renderer, markdown_extensions)
|
|
35
|
+
md.render(text.to_s).html_safe
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# @param text [String]
|
|
39
|
+
# @return [Hash{Symbol=>Object}]
|
|
40
|
+
def self.render_with_toc(text)
|
|
41
|
+
renderer = HtmlRenderer.new
|
|
42
|
+
md = Redcarpet::Markdown.new(renderer, markdown_extensions)
|
|
43
|
+
html = md.render(text.to_s).html_safe
|
|
44
|
+
{ html: html, toc: renderer.toc_items }
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def self.markdown_extensions
|
|
50
|
+
{
|
|
51
|
+
autolink: true,
|
|
52
|
+
tables: true,
|
|
53
|
+
fenced_code_blocks: true,
|
|
54
|
+
strikethrough: true,
|
|
55
|
+
highlight: true,
|
|
56
|
+
superscript: true,
|
|
57
|
+
underline: true,
|
|
58
|
+
no_intra_emphasis: true,
|
|
59
|
+
space_after_headers: true,
|
|
60
|
+
lax_spacing: true
|
|
61
|
+
}
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Rough reading time estimate assuming 200 wpm.
|
|
65
|
+
# @return [Integer]
|
|
66
|
+
def reading_time_minutes
|
|
67
|
+
words = markdown.scan(/\b[\p{L}\p{N}']+\b/).size
|
|
68
|
+
[ (words / 200.0).ceil, 1 ].max
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
class HtmlRenderer < Redcarpet::Render::HTML
|
|
72
|
+
include Rouge::Plugins::Redcarpet
|
|
73
|
+
|
|
74
|
+
attr_reader :toc_items
|
|
75
|
+
|
|
76
|
+
def initialize(extensions = {})
|
|
77
|
+
super(extensions.merge(
|
|
78
|
+
hard_wrap: true,
|
|
79
|
+
link_attributes: { target: "_blank", rel: "noopener noreferrer" },
|
|
80
|
+
with_toc_data: true
|
|
81
|
+
))
|
|
82
|
+
@toc_items = []
|
|
83
|
+
@heading_ids = Hash.new(0)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def block_code(code, language)
|
|
87
|
+
language ||= "text"
|
|
88
|
+
lexer = Rouge::Lexer.find_fancy(language, code) || Rouge::Lexers::PlainText.new
|
|
89
|
+
formatter = Rouge::Formatters::HTML.new
|
|
90
|
+
highlighted = formatter.format(lexer.lex(code))
|
|
91
|
+
|
|
92
|
+
lang_label = language != "text" ? %(<span class="code-lang">#{language}</span>) : ""
|
|
93
|
+
%(<div class="code-block">#{lang_label}<pre class="highlight #{language}"><code>#{highlighted}</code></pre></div>)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def header(text, header_level)
|
|
97
|
+
base_slug = text.to_s.downcase.strip.gsub(/\s+/, "-").gsub(/[^\w-]/, "")
|
|
98
|
+
base_slug = "section" if base_slug.blank?
|
|
99
|
+
|
|
100
|
+
@heading_ids[base_slug] += 1
|
|
101
|
+
slug = @heading_ids[base_slug] > 1 ? "#{base_slug}-#{@heading_ids[base_slug]}" : base_slug
|
|
102
|
+
|
|
103
|
+
if header_level >= 2 && header_level <= 4
|
|
104
|
+
@toc_items << { level: header_level, id: slug, text: text }
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
%(<h#{header_level} id="#{slug}">#{text}</h#{header_level}>\n)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def table(header, body)
|
|
111
|
+
%(<table class="admin-suite-doc-table"><thead>#{header}</thead><tbody>#{body}</tbody></table>\n)
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "admin_suite/ui/dashboard_definition"
|
|
4
|
+
|
|
5
|
+
module AdminSuite
|
|
6
|
+
class PortalDefinition
|
|
7
|
+
attr_reader :key
|
|
8
|
+
|
|
9
|
+
def initialize(key)
|
|
10
|
+
@key = key.to_sym
|
|
11
|
+
@label = nil
|
|
12
|
+
@icon = nil
|
|
13
|
+
@color = nil
|
|
14
|
+
@order = nil
|
|
15
|
+
@description = nil
|
|
16
|
+
@dashboard = nil
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def label(value = nil)
|
|
20
|
+
@label = value if value.present?
|
|
21
|
+
@label
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def icon(value = nil)
|
|
25
|
+
@icon = value if value.present?
|
|
26
|
+
@icon
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def color(value = nil)
|
|
30
|
+
@color = value if value.present?
|
|
31
|
+
@color
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def order(value = nil)
|
|
35
|
+
@order = value unless value.nil?
|
|
36
|
+
@order
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def description(value = nil)
|
|
40
|
+
@description = value if value.present?
|
|
41
|
+
@description
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def dashboard(&block)
|
|
45
|
+
@dashboard ||= UI::DashboardDefinition.new
|
|
46
|
+
UI::DashboardDSL.new(@dashboard).instance_eval(&block) if block_given?
|
|
47
|
+
@dashboard
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def dashboard_definition
|
|
51
|
+
@dashboard
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def to_nav_meta
|
|
55
|
+
{
|
|
56
|
+
label: @label,
|
|
57
|
+
icon: @icon,
|
|
58
|
+
color: @color,
|
|
59
|
+
order: @order,
|
|
60
|
+
description: @description
|
|
61
|
+
}.compact
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AdminSuite
|
|
4
|
+
# Stores portal definitions registered via `AdminSuite.portal`.
|
|
5
|
+
module PortalRegistry
|
|
6
|
+
class << self
|
|
7
|
+
# @return [Hash{Symbol=>AdminSuite::PortalDefinition}]
|
|
8
|
+
def all
|
|
9
|
+
@all ||= {}
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# @param definition [AdminSuite::PortalDefinition]
|
|
13
|
+
# @return [AdminSuite::PortalDefinition]
|
|
14
|
+
def register(definition)
|
|
15
|
+
all[definition.key] = definition
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# @param key [Symbol, String]
|
|
19
|
+
# @return [AdminSuite::PortalDefinition, nil]
|
|
20
|
+
def fetch(key)
|
|
21
|
+
all[key.to_sym]
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Clears the registry (useful for development reloads).
|
|
25
|
+
#
|
|
26
|
+
# @return [void]
|
|
27
|
+
def reset!
|
|
28
|
+
@all = {}
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AdminSuite
|
|
4
|
+
module ThemePalette
|
|
5
|
+
# Minimal Tailwind-like palette values (hex) for theming.
|
|
6
|
+
# We only include the shades AdminSuite uses.
|
|
7
|
+
COLORS = {
|
|
8
|
+
"slate" => { 100 => "#f1f5f9", 200 => "#e2e8f0", 500 => "#64748b", 600 => "#475569", 700 => "#334155", 800 => "#1e293b", 900 => "#0f172a" },
|
|
9
|
+
"indigo" => { 100 => "#e0e7ff", 200 => "#c7d2fe", 500 => "#6366f1", 600 => "#4f46e5", 700 => "#4338ca", 800 => "#3730a3", 900 => "#312e81" },
|
|
10
|
+
"purple" => { 100 => "#f3e8ff", 200 => "#e9d5ff", 500 => "#a855f7", 600 => "#9333ea", 700 => "#7e22ce", 800 => "#6b21a8", 900 => "#581c87" },
|
|
11
|
+
"violet" => { 100 => "#ede9fe", 200 => "#ddd6fe", 500 => "#8b5cf6", 600 => "#7c3aed", 700 => "#6d28d9", 800 => "#5b21b6", 900 => "#4c1d95" },
|
|
12
|
+
"amber" => { 100 => "#fef3c7", 200 => "#fde68a", 500 => "#f59e0b", 600 => "#d97706", 700 => "#b45309", 800 => "#92400e", 900 => "#78350f" },
|
|
13
|
+
"emerald" => { 100 => "#d1fae5", 200 => "#a7f3d0", 500 => "#10b981", 600 => "#059669", 700 => "#047857", 800 => "#065f46", 900 => "#064e3b" },
|
|
14
|
+
"cyan" => { 100 => "#cffafe", 200 => "#a5f3fc", 500 => "#06b6d4", 600 => "#0891b2", 700 => "#0e7490", 800 => "#155e75", 900 => "#164e63" },
|
|
15
|
+
"blue" => { 100 => "#dbeafe", 200 => "#bfdbfe", 500 => "#3b82f6", 600 => "#2563eb", 700 => "#1d4ed8", 800 => "#1e40af", 900 => "#1e3a8a" },
|
|
16
|
+
"green" => { 100 => "#dcfce7", 200 => "#bbf7d0", 500 => "#22c55e", 600 => "#16a34a", 700 => "#15803d", 800 => "#166534", 900 => "#14532d" },
|
|
17
|
+
"red" => { 100 => "#fee2e2", 200 => "#fecaca", 500 => "#ef4444", 600 => "#dc2626", 700 => "#b91c1c", 800 => "#991b1b", 900 => "#7f1d1d" }
|
|
18
|
+
}.freeze
|
|
19
|
+
|
|
20
|
+
def self.resolve(color_name, shade, fallback: nil)
|
|
21
|
+
return fallback if color_name.blank?
|
|
22
|
+
|
|
23
|
+
name = color_name.to_s.delete_prefix(":")
|
|
24
|
+
COLORS.dig(name, shade) || fallback
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def self.normalize_color(value, default_name:)
|
|
28
|
+
return default_name.to_s if value.blank?
|
|
29
|
+
value.to_s.delete_prefix(":")
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def self.hex?(value)
|
|
33
|
+
value.is_a?(String) && value.match?(/\A#(?:[0-9a-fA-F]{3}){1,2}\z/)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AdminSuite
|
|
4
|
+
module UI
|
|
5
|
+
PanelDefinition = Struct.new(:type, :title, :options, keyword_init: true)
|
|
6
|
+
RowDefinition = Struct.new(:panels, keyword_init: true)
|
|
7
|
+
|
|
8
|
+
class DashboardDefinition
|
|
9
|
+
attr_reader :rows
|
|
10
|
+
|
|
11
|
+
def initialize
|
|
12
|
+
@rows = []
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# DSL used inside `portal.dashboard do ... end`.
|
|
17
|
+
class DashboardDSL
|
|
18
|
+
def initialize(definition)
|
|
19
|
+
@definition = definition
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def row(&block)
|
|
23
|
+
row = RowDefinition.new(panels: [])
|
|
24
|
+
RowDSL.new(row).instance_eval(&block) if block_given?
|
|
25
|
+
@definition.rows << row
|
|
26
|
+
row
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# DSL used inside `row do ... end`.
|
|
31
|
+
class RowDSL
|
|
32
|
+
def initialize(row)
|
|
33
|
+
@row = row
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def panel(type, title = nil, span: nil, **options, &block)
|
|
37
|
+
options[:span] = span if span
|
|
38
|
+
options[:block] = block if block_given?
|
|
39
|
+
@row.panels << PanelDefinition.new(type: type.to_sym, title: title, options: options)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def stat_panel(title, value = nil, span: nil, **options, &block)
|
|
43
|
+
value_proc = value.is_a?(Proc) ? value : (block_given? ? block : nil)
|
|
44
|
+
panel(:stat, title, span: span, **options.merge(value: value_proc || value))
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def health_panel(title, status: nil, metrics: nil, span: nil, **options, &block)
|
|
48
|
+
panel(:health, title, span: span, **options.merge(status: status, metrics: metrics, block: block))
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def chart_panel(title, data: nil, span: nil, **options, &block)
|
|
52
|
+
data_proc = data.is_a?(Proc) ? data : (block_given? ? block : nil)
|
|
53
|
+
panel(:chart, title, span: span, **options.merge(data: data_proc || data))
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def cards_panel(title, resources: nil, span: nil, **options, &block)
|
|
57
|
+
panel(:cards, title, span: span, **options.merge(resources: resources, block: block))
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def recent_panel(title, scope: nil, link: nil, span: nil, **options, &block)
|
|
61
|
+
panel(:recent, title, span: span, **options.merge(scope: scope, link: link, block: block))
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def table_panel(title, rows: nil, columns: nil, span: nil, **options, &block)
|
|
65
|
+
panel(:table, title, span: span, **options.merge(rows: rows, columns: columns, block: block))
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AdminSuite
|
|
4
|
+
module UI
|
|
5
|
+
module FieldRendererRegistry
|
|
6
|
+
class << self
|
|
7
|
+
def handlers
|
|
8
|
+
@handlers ||= {}
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def register(type, &block)
|
|
12
|
+
handlers[type.to_sym] = block
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def render(type, view:, f:, field:, resource:, field_class:)
|
|
16
|
+
handler = handlers[type.to_sym]
|
|
17
|
+
return nil unless handler
|
|
18
|
+
|
|
19
|
+
handler.call(view, f, field, resource, field_class)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# ---- default field renderers ----
|
|
27
|
+
AdminSuite::UI::FieldRendererRegistry.register(:textarea) do |_view, f, field, resource, field_class|
|
|
28
|
+
f.text_area(field.name, class: field_class, rows: field.rows || 4, placeholder: field.placeholder, readonly: field.readonly)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
AdminSuite::UI::FieldRendererRegistry.register(:url) do |_view, f, field, resource, field_class|
|
|
32
|
+
f.url_field(field.name, class: field_class, placeholder: field.placeholder, readonly: field.readonly)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
AdminSuite::UI::FieldRendererRegistry.register(:email) do |_view, f, field, resource, field_class|
|
|
36
|
+
f.email_field(field.name, class: field_class, placeholder: field.placeholder, readonly: field.readonly)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
AdminSuite::UI::FieldRendererRegistry.register(:number) do |_view, f, field, resource, field_class|
|
|
40
|
+
f.number_field(field.name, class: field_class, placeholder: field.placeholder, readonly: field.readonly)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
AdminSuite::UI::FieldRendererRegistry.register(:toggle) do |view, f, field, resource, _field_class|
|
|
44
|
+
view.render_toggle_field(f, field, resource)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
AdminSuite::UI::FieldRendererRegistry.register(:label) do |view, _f, field, resource, _field_class|
|
|
48
|
+
label_value = resource.public_send(field.name) rescue nil
|
|
49
|
+
view.render_label_badge(label_value, color: field.label_color, size: field.label_size, record: resource)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
AdminSuite::UI::FieldRendererRegistry.register(:select) do |_view, f, field, resource, field_class|
|
|
53
|
+
collection = field.collection.is_a?(Proc) ? field.collection.call : field.collection
|
|
54
|
+
f.select(field.name, collection, { include_blank: true }, class: field_class, disabled: field.readonly)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
AdminSuite::UI::FieldRendererRegistry.register(:searchable_select) do |view, f, field, resource, _field_class|
|
|
58
|
+
view.render_searchable_select(f, field, resource)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
AdminSuite::UI::FieldRendererRegistry.register(:multi_select) do |view, f, field, resource, _field_class|
|
|
62
|
+
view.render_multi_select(f, field, resource)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
AdminSuite::UI::FieldRendererRegistry.register(:tags) do |view, f, field, resource, _field_class|
|
|
66
|
+
view.render_multi_select(f, field, resource)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
AdminSuite::UI::FieldRendererRegistry.register(:image) do |view, f, field, resource, _field_class|
|
|
70
|
+
view.render_file_upload(f, field, resource)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
AdminSuite::UI::FieldRendererRegistry.register(:attachment) do |view, f, field, resource, _field_class|
|
|
74
|
+
view.render_file_upload(f, field, resource)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
AdminSuite::UI::FieldRendererRegistry.register(:trix) do |_view, f, field, resource, _field_class|
|
|
78
|
+
f.rich_text_area(field.name, class: "prose dark:prose-invert max-w-none")
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
AdminSuite::UI::FieldRendererRegistry.register(:rich_text) do |_view, f, field, resource, _field_class|
|
|
82
|
+
f.rich_text_area(field.name, class: "prose dark:prose-invert max-w-none")
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
AdminSuite::UI::FieldRendererRegistry.register(:markdown) do |_view, f, field, resource, field_class|
|
|
86
|
+
f.text_area(field.name, class: "#{field_class} font-mono", rows: field.rows || 12, data: { controller: "admin-suite--markdown-editor" }, placeholder: field.placeholder)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
AdminSuite::UI::FieldRendererRegistry.register(:file) do |_view, f, field, resource, _field_class|
|
|
90
|
+
f.file_field(field.name, class: "form-input-file", accept: field.accept)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
AdminSuite::UI::FieldRendererRegistry.register(:datetime) do |_view, f, field, resource, field_class|
|
|
94
|
+
f.datetime_local_field(field.name, class: field_class, readonly: field.readonly)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
AdminSuite::UI::FieldRendererRegistry.register(:date) do |_view, f, field, resource, field_class|
|
|
98
|
+
f.date_field(field.name, class: field_class, readonly: field.readonly)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
AdminSuite::UI::FieldRendererRegistry.register(:time) do |_view, f, field, resource, field_class|
|
|
102
|
+
f.time_field(field.name, class: field_class, readonly: field.readonly)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
AdminSuite::UI::FieldRendererRegistry.register(:json) do |view, f, field, resource, _field_class|
|
|
106
|
+
view.render("admin_suite/shared/json_editor_field", f: f, field: field, resource: resource)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
AdminSuite::UI::FieldRendererRegistry.register(:code) do |view, f, field, resource, _field_class|
|
|
110
|
+
view.render_code_editor(f, field, resource)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
AdminSuite::UI::FieldRendererRegistry.register(:text) do |_view, f, field, resource, field_class|
|
|
114
|
+
f.text_field(field.name, class: field_class, placeholder: field.placeholder, readonly: field.readonly)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
AdminSuite::UI::FieldRendererRegistry.register(:string) do |_view, f, field, resource, field_class|
|
|
118
|
+
f.text_field(field.name, class: field_class, placeholder: field.placeholder, readonly: field.readonly)
|
|
119
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "admin_suite/ui/field_renderer_registry"
|
|
4
|
+
|
|
5
|
+
module AdminSuite
|
|
6
|
+
module UI
|
|
7
|
+
# Overrides `render_form_field` to use a registry of field renderers,
|
|
8
|
+
# while leaving the legacy implementation available via `super`.
|
|
9
|
+
module FormFieldRenderer
|
|
10
|
+
def render_form_field(f, field, resource)
|
|
11
|
+
return super unless defined?(AdminSuite::UI::FieldRendererRegistry)
|
|
12
|
+
|
|
13
|
+
return if field.if_condition.present? && !field.if_condition.call(resource)
|
|
14
|
+
return if field.unless_condition.present? && field.unless_condition.call(resource)
|
|
15
|
+
|
|
16
|
+
capture do
|
|
17
|
+
concat(content_tag(:div, class: "form-group") do
|
|
18
|
+
concat(f.label(field.name, class: "form-label") do
|
|
19
|
+
concat(field.label)
|
|
20
|
+
concat(content_tag(:span, " *", class: "text-red-500")) if field.required
|
|
21
|
+
end)
|
|
22
|
+
|
|
23
|
+
field_class = "form-input w-full"
|
|
24
|
+
field_class += " border-red-500" if resource.errors[field.name].any?
|
|
25
|
+
|
|
26
|
+
field_html =
|
|
27
|
+
AdminSuite::UI::FieldRendererRegistry.render(
|
|
28
|
+
field.type || :text,
|
|
29
|
+
view: self,
|
|
30
|
+
f: f,
|
|
31
|
+
field: field,
|
|
32
|
+
resource: resource,
|
|
33
|
+
field_class: field_class
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
# If the registry doesn't know how to render, fall back to legacy behavior.
|
|
37
|
+
return super if field_html.nil?
|
|
38
|
+
|
|
39
|
+
concat(field_html)
|
|
40
|
+
|
|
41
|
+
concat(content_tag(:p, field.help, class: "mt-1 text-sm text-slate-500 dark:text-slate-400")) if field.help.present?
|
|
42
|
+
concat(content_tag(:p, resource.errors[field.name].first, class: "mt-1 text-sm text-red-600 dark:text-red-400")) if resource.errors[field.name].any?
|
|
43
|
+
end)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AdminSuite
|
|
4
|
+
module UI
|
|
5
|
+
module ShowFormatterRegistry
|
|
6
|
+
class << self
|
|
7
|
+
def class_handlers
|
|
8
|
+
@class_handlers ||= {}
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def default_handler
|
|
12
|
+
@default_handler
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def register_class(klass, &block)
|
|
16
|
+
class_handlers[klass] = block
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def register_default(&block)
|
|
20
|
+
@default_handler = block
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def format(value, view:, record:, field_name:)
|
|
24
|
+
handler = class_handlers.find { |klass, _| value.is_a?(klass) }&.last
|
|
25
|
+
handler ||= default_handler
|
|
26
|
+
return nil unless handler
|
|
27
|
+
|
|
28
|
+
handler.call(value, view, record, field_name)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# ---- default show formatters ----
|
|
36
|
+
AdminSuite::UI::ShowFormatterRegistry.register_class(NilClass) do |_value, view, _record, _field|
|
|
37
|
+
view.content_tag(:span, "—", class: "text-slate-400")
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
AdminSuite::UI::ShowFormatterRegistry.register_class(TrueClass) do |_value, view, _record, _field|
|
|
41
|
+
view.content_tag(:span, class: "inline-flex items-center gap-1") do
|
|
42
|
+
view.concat(view.admin_suite_icon("check-circle-2", class: "w-4 h-4 text-green-500"))
|
|
43
|
+
view.concat(view.content_tag(:span, "Yes", class: "text-green-600 dark:text-green-400 font-medium"))
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
AdminSuite::UI::ShowFormatterRegistry.register_class(FalseClass) do |_value, view, _record, _field|
|
|
48
|
+
view.content_tag(:span, class: "inline-flex items-center gap-1") do
|
|
49
|
+
view.concat(view.admin_suite_icon("x-circle", class: "w-4 h-4 text-slate-400"))
|
|
50
|
+
view.concat(view.content_tag(:span, "No", class: "text-slate-500"))
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
AdminSuite::UI::ShowFormatterRegistry.register_class(Time) do |value, view, _record, _field|
|
|
55
|
+
view.content_tag(:span, class: "inline-flex items-center gap-2") do
|
|
56
|
+
view.concat(view.content_tag(:span, value.strftime("%B %d, %Y at %H:%M"), class: "font-medium"))
|
|
57
|
+
view.concat(view.content_tag(:span, "(#{view.time_ago_in_words(value)} ago)", class: "text-slate-500 dark:text-slate-400 text-xs"))
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
AdminSuite::UI::ShowFormatterRegistry.register_class(DateTime) do |value, view, _record, _field|
|
|
62
|
+
view.content_tag(:span, class: "inline-flex items-center gap-2") do
|
|
63
|
+
view.concat(view.content_tag(:span, value.strftime("%B %d, %Y at %H:%M"), class: "font-medium"))
|
|
64
|
+
view.concat(view.content_tag(:span, "(#{view.time_ago_in_words(value)} ago)", class: "text-slate-500 dark:text-slate-400 text-xs"))
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
AdminSuite::UI::ShowFormatterRegistry.register_class(Date) do |value, _view, _record, _field|
|
|
69
|
+
value.strftime("%B %d, %Y")
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
if defined?(ActiveRecord::Base)
|
|
73
|
+
AdminSuite::UI::ShowFormatterRegistry.register_class(ActiveRecord::Base) do |value, view, _record, _field|
|
|
74
|
+
link_text = value.respond_to?(:name) ? value.name : "#{value.class.name} ##{value.id}"
|
|
75
|
+
view.content_tag(:span, link_text, class: "text-indigo-600 dark:text-indigo-400")
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
AdminSuite::UI::ShowFormatterRegistry.register_class(Hash) do |value, view, _record, _field|
|
|
80
|
+
view.render_json_block(value)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
AdminSuite::UI::ShowFormatterRegistry.register_class(Array) do |value, view, _record, _field|
|
|
84
|
+
if value.empty?
|
|
85
|
+
view.content_tag(:span, "Empty array", class: "text-slate-400 italic")
|
|
86
|
+
elsif value.first.is_a?(Hash)
|
|
87
|
+
view.render_json_block(value)
|
|
88
|
+
else
|
|
89
|
+
view.content_tag(:div, class: "flex flex-wrap gap-1") do
|
|
90
|
+
value.each do |item|
|
|
91
|
+
view.concat(view.content_tag(:span, item.to_s, class: "inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-slate-100 dark:bg-slate-700 text-slate-700 dark:text-slate-300"))
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
AdminSuite::UI::ShowFormatterRegistry.register_class(Integer) do |value, view, _record, _field|
|
|
98
|
+
view.content_tag(:span, view.number_with_delimiter(value), class: "font-mono")
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
AdminSuite::UI::ShowFormatterRegistry.register_class(Float) do |value, view, _record, _field|
|
|
102
|
+
view.content_tag(:span, view.number_with_delimiter(value), class: "font-mono")
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
AdminSuite::UI::ShowFormatterRegistry.register_default do |value, view, _record, field_name|
|
|
106
|
+
value_str = value.to_s
|
|
107
|
+
|
|
108
|
+
if value_str.start_with?("{", "[") && value_str.length > 10
|
|
109
|
+
begin
|
|
110
|
+
parsed = JSON.parse(value_str)
|
|
111
|
+
view.render_json_block(parsed)
|
|
112
|
+
rescue JSON::ParserError
|
|
113
|
+
view.render_text_block(value_str)
|
|
114
|
+
end
|
|
115
|
+
elsif value_str.include?("\n") || value_str.length > 200
|
|
116
|
+
view.render_text_block(value_str, view.detect_language(field_name, value_str))
|
|
117
|
+
else
|
|
118
|
+
value_str
|
|
119
|
+
end
|
|
120
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "admin_suite/ui/show_formatter_registry"
|
|
4
|
+
|
|
5
|
+
module AdminSuite
|
|
6
|
+
module UI
|
|
7
|
+
# Overrides `format_show_value` to use a registry of show value formatters,
|
|
8
|
+
# while leaving the legacy implementation available via `super`.
|
|
9
|
+
module ShowValueFormatter
|
|
10
|
+
def format_show_value(record, field_name)
|
|
11
|
+
value = record.public_send(field_name) rescue nil
|
|
12
|
+
|
|
13
|
+
if (field_def = admin_suite_field_definition(field_name))
|
|
14
|
+
case field_def.type
|
|
15
|
+
when :markdown
|
|
16
|
+
rendered =
|
|
17
|
+
if defined?(::MarkdownRenderer)
|
|
18
|
+
::MarkdownRenderer.render(value.to_s)
|
|
19
|
+
else
|
|
20
|
+
simple_format(value.to_s)
|
|
21
|
+
end
|
|
22
|
+
return content_tag(:div, rendered, class: "prose dark:prose-invert max-w-none")
|
|
23
|
+
when :json
|
|
24
|
+
begin
|
|
25
|
+
parsed =
|
|
26
|
+
if value.is_a?(Hash) || value.is_a?(Array)
|
|
27
|
+
value
|
|
28
|
+
elsif value.present?
|
|
29
|
+
JSON.parse(value.to_s)
|
|
30
|
+
end
|
|
31
|
+
return render_json_block(parsed) if parsed
|
|
32
|
+
rescue JSON::ParserError
|
|
33
|
+
# fall through
|
|
34
|
+
end
|
|
35
|
+
when :label
|
|
36
|
+
return render_label_badge(value, color: field_def.label_color, size: field_def.label_size, record: record)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# If the field isn't in the form config, fall back to index column config
|
|
41
|
+
# so show pages can still render labels consistently.
|
|
42
|
+
if respond_to?(:resource_config, true) && (rc = resource_config) && rc.index_config&.columns_list
|
|
43
|
+
col = rc.index_config.columns_list.find { |c| c.name.to_sym == field_name.to_sym }
|
|
44
|
+
if col&.type == :label
|
|
45
|
+
label_value = col.content.is_a?(Proc) ? col.content.call(record) : value
|
|
46
|
+
return render_label_badge(label_value, color: col.label_color, size: col.label_size, record: record)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
if value.is_a?(ActiveStorage::Attached::One)
|
|
51
|
+
return render_attachment_preview(value)
|
|
52
|
+
elsif value.is_a?(ActiveStorage::Attached::Many)
|
|
53
|
+
return render_attachments_preview(value)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
formatted =
|
|
57
|
+
AdminSuite::UI::ShowFormatterRegistry.format(
|
|
58
|
+
value,
|
|
59
|
+
view: self,
|
|
60
|
+
record: record,
|
|
61
|
+
field_name: field_name
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
return formatted unless formatted.nil?
|
|
65
|
+
|
|
66
|
+
super
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|