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,52 @@
|
|
|
1
|
+
<%# JSON Editor Field for Admin Suite Forms
|
|
2
|
+
Parameters:
|
|
3
|
+
- f: Form builder object (required)
|
|
4
|
+
- field: FieldDefinition object (required)
|
|
5
|
+
- resource: The resource being edited (required)
|
|
6
|
+
-%>
|
|
7
|
+
<%
|
|
8
|
+
value = resource.public_send(field.name)
|
|
9
|
+
rows = field.rows || 10
|
|
10
|
+
formatted_value = if value.is_a?(Hash) || value.is_a?(Array)
|
|
11
|
+
JSON.pretty_generate(value)
|
|
12
|
+
elsif value.present?
|
|
13
|
+
value.to_s
|
|
14
|
+
else
|
|
15
|
+
"{}"
|
|
16
|
+
end
|
|
17
|
+
%>
|
|
18
|
+
|
|
19
|
+
<div data-controller="admin-suite--json-editor">
|
|
20
|
+
<% if field.help.present? %>
|
|
21
|
+
<p class="text-sm text-slate-500 dark:text-slate-400 mb-2"><%= field.help %></p>
|
|
22
|
+
<% else %>
|
|
23
|
+
<p class="text-sm text-slate-500 dark:text-slate-400 mb-2">Enter valid JSON. Use the Format button to pretty-print.</p>
|
|
24
|
+
<% end %>
|
|
25
|
+
|
|
26
|
+
<div class="relative">
|
|
27
|
+
<%= f.text_area field.name,
|
|
28
|
+
rows: rows,
|
|
29
|
+
class: "form-input w-full font-mono text-sm",
|
|
30
|
+
value: formatted_value,
|
|
31
|
+
readonly: field.readonly,
|
|
32
|
+
data: {
|
|
33
|
+
"admin-suite--json-editor-target": "input",
|
|
34
|
+
action: "input->admin-suite--json-editor#validate"
|
|
35
|
+
} %>
|
|
36
|
+
|
|
37
|
+
<div class="absolute top-2 right-2">
|
|
38
|
+
<button
|
|
39
|
+
type="button"
|
|
40
|
+
data-action="click->admin-suite--json-editor#format"
|
|
41
|
+
class="px-3 py-1.5 text-xs font-medium text-slate-700 dark:text-slate-300 bg-slate-100 dark:bg-slate-700 hover:bg-slate-200 dark:hover:bg-slate-600 rounded transition-colors"
|
|
42
|
+
>
|
|
43
|
+
Format JSON
|
|
44
|
+
</button>
|
|
45
|
+
</div>
|
|
46
|
+
</div>
|
|
47
|
+
|
|
48
|
+
<div
|
|
49
|
+
data-admin-suite--json-editor-target="error"
|
|
50
|
+
class="hidden mt-2 text-sm text-red-600 dark:text-red-400"
|
|
51
|
+
></div>
|
|
52
|
+
</div>
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
<%# Admin Suite Sidebar - mirrors internal/developer styling %>
|
|
2
|
+
|
|
3
|
+
<% sorted_portals = navigation_items.sort_by { |(_key, meta)| (meta[:order] || 100).to_i } %>
|
|
4
|
+
|
|
5
|
+
<aside class="admin-suite-sidebar flex flex-col w-72 h-full">
|
|
6
|
+
<!-- Header -->
|
|
7
|
+
<div class="flex items-center justify-between h-16 px-4 border-b border-white/10">
|
|
8
|
+
<%= link_to root_path, class: "flex items-center gap-3" do %>
|
|
9
|
+
<div class="flex items-center justify-center w-9 h-9 rounded-lg bg-white/10 border border-white/15">
|
|
10
|
+
<%= admin_suite_icon("code", class: "w-5 h-5 text-white") %>
|
|
11
|
+
</div>
|
|
12
|
+
<div>
|
|
13
|
+
<span class="text-lg font-bold text-white">Developer</span>
|
|
14
|
+
<span class="block text-xs text-white/80">Admin Suite</span>
|
|
15
|
+
</div>
|
|
16
|
+
<% end %>
|
|
17
|
+
</div>
|
|
18
|
+
|
|
19
|
+
<!-- Quick Links -->
|
|
20
|
+
<div class="px-3 py-3 border-b border-white/10">
|
|
21
|
+
<div class="flex gap-2">
|
|
22
|
+
<%= link_to root_path,
|
|
23
|
+
class: "flex-1 px-3 py-2 text-xs font-medium text-white bg-white/10 rounded-lg hover:bg-white/20 text-center transition-colors" do %>
|
|
24
|
+
Dashboard
|
|
25
|
+
<% end %>
|
|
26
|
+
<% docs = AdminSuite.config.docs_url %>
|
|
27
|
+
<% docs_url = docs.respond_to?(:call) ? docs.call(self) : docs %>
|
|
28
|
+
<% if docs_url.present? %>
|
|
29
|
+
<%= link_to docs_url,
|
|
30
|
+
class: "flex-1 px-3 py-2 text-xs font-medium text-white bg-white/10 rounded-lg hover:bg-white/20 text-center transition-colors",
|
|
31
|
+
target: "_blank",
|
|
32
|
+
rel: "noopener noreferrer" do %>
|
|
33
|
+
Docs
|
|
34
|
+
<% end %>
|
|
35
|
+
<% else %>
|
|
36
|
+
<%= link_to docs_path,
|
|
37
|
+
class: "flex-1 px-3 py-2 text-xs font-medium text-white bg-white/10 rounded-lg hover:bg-white/20 text-center transition-colors" do %>
|
|
38
|
+
Docs
|
|
39
|
+
<% end %>
|
|
40
|
+
<% end %>
|
|
41
|
+
</div>
|
|
42
|
+
</div>
|
|
43
|
+
|
|
44
|
+
<!-- Portal Navigation -->
|
|
45
|
+
<nav class="flex-1 overflow-y-auto px-3 py-4 space-y-2">
|
|
46
|
+
<% sorted_portals.each do |portal_key, portal| %>
|
|
47
|
+
<% portal_home = portal_path(portal: portal_key) %>
|
|
48
|
+
<% portal_prefix = File.join(request.script_name.to_s, portal_key.to_s) %>
|
|
49
|
+
<% active = request.path.start_with?(portal_prefix) %>
|
|
50
|
+
<% color = portal_color(portal_key) %>
|
|
51
|
+
|
|
52
|
+
<div>
|
|
53
|
+
<%= link_to (portal_home || root_path),
|
|
54
|
+
class: "flex items-center gap-3 px-3 py-2.5 rounded-lg transition-colors #{active ? "bg-white/20 text-white" : "text-white/90 hover:bg-white/10 hover:text-white"}" do %>
|
|
55
|
+
<div class="admin-suite-portal-accent admin-suite-portal-accent--<%= color %> admin-suite-portal-chip flex items-center justify-center w-8 h-8 rounded-lg <%= active ? "" : "bg-white/10" %>">
|
|
56
|
+
<%= portal_icon(portal_key, class: "admin-suite-portal-icon w-4 h-4") %>
|
|
57
|
+
</div>
|
|
58
|
+
<span class="font-medium"><%= portal[:label] %></span>
|
|
59
|
+
<% end %>
|
|
60
|
+
|
|
61
|
+
<% if active %>
|
|
62
|
+
<div class="mt-1 ml-11 space-y-0.5 text-sm">
|
|
63
|
+
<% portal[:sections].sort_by { |(_k, s)| s[:label].to_s }.each do |_section_key, section| %>
|
|
64
|
+
<div class="text-xs font-medium text-white/70 uppercase tracking-wider px-3 pt-2 pb-1">
|
|
65
|
+
<%= section[:label] %>
|
|
66
|
+
</div>
|
|
67
|
+
<% section[:items].sort_by { |it| [ (it[:order] || 100).to_i, it[:label].to_s ] }.each do |item| %>
|
|
68
|
+
<% item_active = request.path.start_with?(item[:path].to_s) %>
|
|
69
|
+
<%= link_to item[:path],
|
|
70
|
+
class: "flex items-center gap-2 px-3 py-1.5 rounded #{item_active ? "text-white bg-white/20" : "text-white/90 hover:bg-white/10 hover:text-white"}",
|
|
71
|
+
data: { turbo_frame: "_top" } do %>
|
|
72
|
+
<% if item[:icon].present? %>
|
|
73
|
+
<%= admin_suite_icon(item[:icon], class: "w-3.5 h-3.5 text-white/90") %>
|
|
74
|
+
<% end %>
|
|
75
|
+
<span class="truncate"><%= item[:label] %></span>
|
|
76
|
+
<% end %>
|
|
77
|
+
<% end %>
|
|
78
|
+
<% end %>
|
|
79
|
+
</div>
|
|
80
|
+
<% end %>
|
|
81
|
+
</div>
|
|
82
|
+
<% end %>
|
|
83
|
+
</nav>
|
|
84
|
+
|
|
85
|
+
<!-- Footer -->
|
|
86
|
+
<div class="p-3 border-t border-white/10">
|
|
87
|
+
<div class="px-3 py-2 text-xs text-white/70">
|
|
88
|
+
<div class="flex items-center justify-between">
|
|
89
|
+
<span>Admin Suite</span>
|
|
90
|
+
<span class="font-medium text-white/90"><%= sorted_portals.size %> Portals</span>
|
|
91
|
+
</div>
|
|
92
|
+
</div>
|
|
93
|
+
</div>
|
|
94
|
+
</aside>
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
<%# Toggle switch for boolean fields on index pages %>
|
|
2
|
+
<%#
|
|
3
|
+
Required locals:
|
|
4
|
+
- record: The record to toggle
|
|
5
|
+
- field: The boolean field name (e.g., :value, :enabled, :active)
|
|
6
|
+
- toggle_url: (optional) Custom URL for the toggle action
|
|
7
|
+
%>
|
|
8
|
+
<%
|
|
9
|
+
value = record.public_send(field)
|
|
10
|
+
toggle_url ||= begin
|
|
11
|
+
url_for(action: :toggle, id: record.id, field: field)
|
|
12
|
+
rescue ActionController::UrlGenerationError
|
|
13
|
+
resource_toggle_path(portal: params[:portal], resource_name: params[:resource_name], id: record.id, field: field)
|
|
14
|
+
rescue StandardError
|
|
15
|
+
nil
|
|
16
|
+
end
|
|
17
|
+
%>
|
|
18
|
+
|
|
19
|
+
<%= turbo_frame_tag dom_id(record, :toggle), class: "admin-suite-toggle-wrap inline-flex align-middle" do %>
|
|
20
|
+
<% if toggle_url %>
|
|
21
|
+
<%= button_to toggle_url,
|
|
22
|
+
method: :post,
|
|
23
|
+
form: { data: { turbo_frame: dom_id(record, :toggle) }, class: "m-0 inline-flex align-middle items-center" },
|
|
24
|
+
class: "admin-suite-toggle-track #{value ? 'is-on' : ''}" do %>
|
|
25
|
+
<span class="sr-only">Toggle <%= field.to_s.humanize %></span>
|
|
26
|
+
<span class="admin-suite-toggle-thumb"></span>
|
|
27
|
+
<% end %>
|
|
28
|
+
<% else %>
|
|
29
|
+
<%# Read-only toggle display when no toggle URL available %>
|
|
30
|
+
<span class="admin-suite-toggle-track <%= value ? 'is-on' : '' %>">
|
|
31
|
+
<span class="admin-suite-toggle-thumb"></span>
|
|
32
|
+
</span>
|
|
33
|
+
<% end %>
|
|
34
|
+
<% end %>
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
<%# Admin Suite Top Bar (mirrors internal/developer styling) %>
|
|
2
|
+
|
|
3
|
+
<header class="bg-white border-b border-slate-200 h-16 flex-shrink-0">
|
|
4
|
+
<div class="flex items-center justify-between h-full px-4 sm:px-6">
|
|
5
|
+
<!-- Left side -->
|
|
6
|
+
<div class="flex items-center gap-4">
|
|
7
|
+
<!-- Mobile menu button -->
|
|
8
|
+
<button type="button"
|
|
9
|
+
class="lg:hidden p-2 rounded-lg text-slate-500 hover:bg-slate-100"
|
|
10
|
+
data-action="click->admin-suite--sidebar#toggle">
|
|
11
|
+
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
12
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/>
|
|
13
|
+
</svg>
|
|
14
|
+
</button>
|
|
15
|
+
|
|
16
|
+
<!-- Breadcrumb / Current section -->
|
|
17
|
+
<div class="hidden sm:flex items-center gap-2 text-sm">
|
|
18
|
+
<span class="text-slate-400">Admin Suite</span>
|
|
19
|
+
<%= admin_suite_icon("chevron-right", class: "w-4 h-4 text-slate-300") %>
|
|
20
|
+
<span class="font-medium text-slate-900"><%= controller_name.humanize %></span>
|
|
21
|
+
</div>
|
|
22
|
+
</div>
|
|
23
|
+
|
|
24
|
+
<!-- Right side -->
|
|
25
|
+
<div class="flex items-center gap-3">
|
|
26
|
+
<!-- Environment Badge -->
|
|
27
|
+
<span class="hidden sm:inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium <%= theme_badge_primary_class %>">
|
|
28
|
+
<span class="admin-suite-env-dot w-1.5 h-1.5 rounded-full mr-1.5 animate-pulse"></span>
|
|
29
|
+
<%= Rails.env.to_s.humanize %>
|
|
30
|
+
</span>
|
|
31
|
+
|
|
32
|
+
<!-- Actor display -->
|
|
33
|
+
<div class="flex items-center gap-3 pl-3 border-l border-slate-200">
|
|
34
|
+
<div class="text-right hidden sm:block">
|
|
35
|
+
<% actor = admin_suite_actor %>
|
|
36
|
+
<p class="text-sm font-medium text-slate-900">
|
|
37
|
+
<%= actor&.respond_to?(:name) ? actor.name : actor&.respond_to?(:email) ? actor.email : actor&.respond_to?(:email_address) ? actor.email_address : "—" %>
|
|
38
|
+
</p>
|
|
39
|
+
<p class="text-xs text-slate-500">Actor</p>
|
|
40
|
+
</div>
|
|
41
|
+
<div class="admin-suite-avatar flex items-center justify-center w-9 h-9 rounded-full font-semibold text-sm">
|
|
42
|
+
<%= (admin_suite_actor&.respond_to?(:name) && admin_suite_actor.name.present?) ? admin_suite_actor.name.first.upcase : "A" %>
|
|
43
|
+
</div>
|
|
44
|
+
</div>
|
|
45
|
+
</div>
|
|
46
|
+
</div>
|
|
47
|
+
</header>
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html>
|
|
3
|
+
<head>
|
|
4
|
+
<title><%= content_for(:title) || "Admin Suite" %></title>
|
|
5
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
6
|
+
<meta name="apple-mobile-web-app-capable" content="yes">
|
|
7
|
+
<meta name="application-name" content="Admin Suite">
|
|
8
|
+
<meta name="mobile-web-app-capable" content="yes">
|
|
9
|
+
<%= csrf_meta_tags %>
|
|
10
|
+
<%= csp_meta_tag %>
|
|
11
|
+
|
|
12
|
+
<%= yield :head %>
|
|
13
|
+
|
|
14
|
+
<%# AdminSuite ships a baseline stylesheet so it can run without host Tailwind. %>
|
|
15
|
+
<%= stylesheet_link_tag "admin_suite", "data-turbo-track": "reload" %>
|
|
16
|
+
|
|
17
|
+
<%# Engine-build mode: AdminSuite ships its own compiled Tailwind CSS. %>
|
|
18
|
+
<%= stylesheet_link_tag "admin_suite_tailwind", "data-turbo-track": "reload" %>
|
|
19
|
+
|
|
20
|
+
<%# Scoped theme variables for AdminSuite (no host dependency). %>
|
|
21
|
+
<%= admin_suite_theme_style_tag %>
|
|
22
|
+
|
|
23
|
+
<%# Optional: allow host apps to include their own stylesheet after AdminSuite
|
|
24
|
+
(for brand tweaks/overrides). Not required for the engine to function. %>
|
|
25
|
+
<% host_stylesheet = AdminSuite.config.respond_to?(:host_stylesheet) ? AdminSuite.config.host_stylesheet : nil %>
|
|
26
|
+
<% if host_stylesheet.present? %>
|
|
27
|
+
<%= stylesheet_link_tag host_stylesheet, "data-turbo-track": "reload" %>
|
|
28
|
+
<% end %>
|
|
29
|
+
|
|
30
|
+
<%= stylesheet_link_tag "rouge", "data-turbo-track": "reload" %>
|
|
31
|
+
|
|
32
|
+
<%# EasyMDE Markdown Editor %>
|
|
33
|
+
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/easymde@2.18.0/dist/easymde.min.css" data-turbo-track="reload">
|
|
34
|
+
<script src="https://cdn.jsdelivr.net/npm/easymde@2.18.0/dist/easymde.min.js" data-turbo-track="reload"></script>
|
|
35
|
+
|
|
36
|
+
<% if respond_to?(:javascript_importmap_tags) %>
|
|
37
|
+
<%= javascript_importmap_tags %>
|
|
38
|
+
<% end %>
|
|
39
|
+
</head>
|
|
40
|
+
|
|
41
|
+
<body class="admin-suite bg-slate-50">
|
|
42
|
+
<div class="flex h-screen overflow-hidden" data-controller="admin-suite--sidebar">
|
|
43
|
+
<!-- Sidebar -->
|
|
44
|
+
<div class="hidden lg:flex lg:flex-shrink-0">
|
|
45
|
+
<%= render "admin_suite/shared/sidebar" %>
|
|
46
|
+
</div>
|
|
47
|
+
|
|
48
|
+
<!-- Mobile sidebar overlay -->
|
|
49
|
+
<div data-admin-suite--sidebar-target="overlay"
|
|
50
|
+
class="hidden fixed inset-0 z-40 bg-slate-600 bg-opacity-75 lg:hidden"
|
|
51
|
+
data-action="click->admin-suite--sidebar#close">
|
|
52
|
+
</div>
|
|
53
|
+
|
|
54
|
+
<!-- Mobile sidebar -->
|
|
55
|
+
<div data-admin-suite--sidebar-target="mobileSidebar"
|
|
56
|
+
class="hidden fixed inset-y-0 left-0 z-50 w-72 transform transition-transform duration-300 ease-in-out lg:hidden">
|
|
57
|
+
<%= render "admin_suite/shared/sidebar" %>
|
|
58
|
+
</div>
|
|
59
|
+
|
|
60
|
+
<!-- Main content area -->
|
|
61
|
+
<div class="flex flex-col flex-1 overflow-hidden">
|
|
62
|
+
<!-- Top bar -->
|
|
63
|
+
<%= render "admin_suite/shared/topbar" %>
|
|
64
|
+
|
|
65
|
+
<!-- Main content -->
|
|
66
|
+
<main class="flex-1 overflow-y-auto bg-slate-50">
|
|
67
|
+
<!-- Flash messages -->
|
|
68
|
+
<% flash_partial = AdminSuite.config.partials[:flash].presence || "admin_suite/shared/flash" %>
|
|
69
|
+
<%= render flash_partial %>
|
|
70
|
+
|
|
71
|
+
<!-- Page content -->
|
|
72
|
+
<div class="py-6">
|
|
73
|
+
<%= yield %>
|
|
74
|
+
</div>
|
|
75
|
+
</main>
|
|
76
|
+
</div>
|
|
77
|
+
</div>
|
|
78
|
+
</body>
|
|
79
|
+
</html>
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Admin
|
|
4
|
+
module Base
|
|
5
|
+
class ActionExecutor
|
|
6
|
+
attr_reader :resource_class, :action_name, :actor
|
|
7
|
+
|
|
8
|
+
alias_method :current_user, :actor
|
|
9
|
+
|
|
10
|
+
Result = Struct.new(:success, :message, :redirect_url, :errors, keyword_init: true) do
|
|
11
|
+
def success? = success
|
|
12
|
+
def failure? = !success
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def initialize(resource_class, action_name, actor)
|
|
16
|
+
@resource_class = resource_class
|
|
17
|
+
@action_name = action_name
|
|
18
|
+
@actor = actor
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def execute_member(record, params = {})
|
|
22
|
+
action = find_member_action
|
|
23
|
+
return failure_result("Action not found") unless action
|
|
24
|
+
return failure_result("Condition not met") unless condition_met?(action, record)
|
|
25
|
+
execute_action(action, record, params)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def execute_bulk(records, params = {})
|
|
29
|
+
action = find_bulk_action
|
|
30
|
+
return failure_result("Action not found") unless action
|
|
31
|
+
|
|
32
|
+
results = records.map { |record| execute_action(action, record, params) }
|
|
33
|
+
success_count = results.count(&:success?)
|
|
34
|
+
failure_count = results.count(&:failure?)
|
|
35
|
+
|
|
36
|
+
if failure_count.zero?
|
|
37
|
+
success_result("Successfully processed #{success_count} records")
|
|
38
|
+
elsif success_count.zero?
|
|
39
|
+
failure_result("Failed to process all #{failure_count} records")
|
|
40
|
+
else
|
|
41
|
+
success_result("Processed #{success_count} records, #{failure_count} failed")
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def execute_collection(scope, params = {})
|
|
46
|
+
action = find_collection_action
|
|
47
|
+
return failure_result("Action not found") unless action
|
|
48
|
+
execute_action(action, scope, params)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def action_definition
|
|
52
|
+
find_member_action || find_bulk_action || find_collection_action
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def actions_config = @resource_class.actions_config
|
|
58
|
+
|
|
59
|
+
def find_member_action
|
|
60
|
+
return nil unless actions_config
|
|
61
|
+
actions_config.member_actions.find { |a| a.name == action_name }
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def find_bulk_action
|
|
65
|
+
return nil unless actions_config
|
|
66
|
+
actions_config.bulk_actions.find { |a| a.name == action_name }
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def find_collection_action
|
|
70
|
+
return nil unless actions_config
|
|
71
|
+
actions_config.collection_actions.find { |a| a.name == action_name }
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def condition_met?(action, record)
|
|
75
|
+
return evaluate_condition(action.if_condition, record) if action.if_condition.present?
|
|
76
|
+
return !evaluate_condition(action.unless_condition, record) if action.unless_condition.present?
|
|
77
|
+
true
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def evaluate_condition(condition_proc, record)
|
|
81
|
+
condition_proc.arity.zero? ? record.instance_exec(&condition_proc) : condition_proc.call(record)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def execute_action(action, target, params)
|
|
85
|
+
result =
|
|
86
|
+
if target.respond_to?(action.name)
|
|
87
|
+
execute_model_method(target, action)
|
|
88
|
+
elsif target.respond_to?("#{action.name}!")
|
|
89
|
+
execute_model_method(target, action, bang: true)
|
|
90
|
+
else
|
|
91
|
+
handler_class = find_handler_class(action)
|
|
92
|
+
handler_class ? execute_handler(handler_class, target, params) : failure_result("No handler found for action: #{action.name}")
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
notify_action_executed(action, target, params, result)
|
|
96
|
+
result
|
|
97
|
+
rescue StandardError => e
|
|
98
|
+
result = failure_result("Error: #{e.message}")
|
|
99
|
+
notify_action_executed(action, target, params, result)
|
|
100
|
+
result
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def execute_model_method(record, action, bang: false)
|
|
104
|
+
method_name = bang ? "#{action.name}!" : action.name
|
|
105
|
+
record.public_send(method_name)
|
|
106
|
+
success_result("#{action.label} completed successfully")
|
|
107
|
+
rescue ActiveRecord::RecordInvalid => e
|
|
108
|
+
failure_result("Validation failed: #{e.record.errors.full_messages.join(', ')}")
|
|
109
|
+
rescue AASM::InvalidTransition => e
|
|
110
|
+
failure_result("Invalid state transition: #{e.message}")
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def find_handler_class(action)
|
|
114
|
+
if defined?(AdminSuite) && AdminSuite.config.resolve_action_handler.present?
|
|
115
|
+
resolved = AdminSuite.config.resolve_action_handler.call(resource_class, action.name)
|
|
116
|
+
return resolved if resolved
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
handler_name = "#{resource_class.resource_name.camelize}#{action.name.to_s.camelize}Action"
|
|
120
|
+
"Admin::Actions::#{handler_name}".constantize
|
|
121
|
+
rescue NameError
|
|
122
|
+
nil
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def execute_handler(handler_class, target, params)
|
|
126
|
+
handler_class.new(target, actor, params).call
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def success_result(message, redirect_url: nil)
|
|
130
|
+
Result.new(success: true, message: message, redirect_url: redirect_url, errors: [])
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def failure_result(message, errors: [])
|
|
134
|
+
Result.new(success: false, message: message, redirect_url: nil, errors: errors)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def notify_action_executed(action, target, params, result)
|
|
138
|
+
return unless defined?(AdminSuite)
|
|
139
|
+
hook = AdminSuite.config.on_action_executed
|
|
140
|
+
return unless hook
|
|
141
|
+
|
|
142
|
+
hook.call(
|
|
143
|
+
actor: actor,
|
|
144
|
+
action_name: action.name,
|
|
145
|
+
resource_class: resource_class,
|
|
146
|
+
subject: target,
|
|
147
|
+
params: params,
|
|
148
|
+
result: result
|
|
149
|
+
)
|
|
150
|
+
rescue StandardError
|
|
151
|
+
nil
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Admin
|
|
4
|
+
module Base
|
|
5
|
+
class ActionHandler
|
|
6
|
+
attr_reader :record, :actor, :params
|
|
7
|
+
|
|
8
|
+
alias_method :current_user, :actor
|
|
9
|
+
|
|
10
|
+
def initialize(record, actor, params = {})
|
|
11
|
+
@record = record
|
|
12
|
+
@actor = actor
|
|
13
|
+
@params = params
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def call
|
|
17
|
+
raise NotImplementedError, "Subclasses must implement #call"
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
protected
|
|
21
|
+
|
|
22
|
+
def success(message, redirect_url: nil)
|
|
23
|
+
Admin::Base::ActionExecutor::Result.new(success: true, message: message, redirect_url: redirect_url, errors: [])
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def failure(message, errors: [])
|
|
27
|
+
Admin::Base::ActionExecutor::Result.new(success: false, message: message, redirect_url: nil, errors: errors)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Admin
|
|
4
|
+
module Base
|
|
5
|
+
class FilterBuilder
|
|
6
|
+
attr_reader :resource_class, :params
|
|
7
|
+
|
|
8
|
+
def initialize(resource_class, params)
|
|
9
|
+
@resource_class = resource_class
|
|
10
|
+
@params = params
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def apply(scope)
|
|
14
|
+
scope = apply_search(scope)
|
|
15
|
+
scope = apply_filters(scope)
|
|
16
|
+
scope = apply_sort(scope)
|
|
17
|
+
scope
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def filter_params
|
|
21
|
+
return {} unless index_config
|
|
22
|
+
|
|
23
|
+
permitted_keys = [ :search, :sort, :sort_direction, :page ]
|
|
24
|
+
permitted_keys += index_config.filters_list.map(&:name)
|
|
25
|
+
|
|
26
|
+
params.permit(*permitted_keys).to_h.symbolize_keys
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def index_config
|
|
32
|
+
@resource_class.index_config
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def apply_search(scope)
|
|
36
|
+
return scope unless index_config
|
|
37
|
+
return scope if params[:search].blank?
|
|
38
|
+
return scope if index_config.searchable_fields.empty?
|
|
39
|
+
return scope if params[:search].to_s.length < 3
|
|
40
|
+
|
|
41
|
+
search_term = "%#{params[:search]}%"
|
|
42
|
+
conditions = index_config.searchable_fields.map { |field| "#{field} ILIKE :search" }.join(" OR ")
|
|
43
|
+
scope.where(conditions, search: search_term)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def apply_filters(scope)
|
|
47
|
+
return scope unless index_config
|
|
48
|
+
|
|
49
|
+
index_config.filters_list.each do |filter|
|
|
50
|
+
scope = apply_filter(scope, filter)
|
|
51
|
+
end
|
|
52
|
+
scope
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def apply_filter(scope, filter)
|
|
56
|
+
# Some "filters" in the UI are really just controls (e.g. sort dropdown).
|
|
57
|
+
# They are handled elsewhere (`apply_sort`) and must not be turned into SQL.
|
|
58
|
+
return scope if %i[sort sort_direction direction page search].include?(filter.name.to_sym)
|
|
59
|
+
|
|
60
|
+
value = params[filter.name]
|
|
61
|
+
return scope if value.blank?
|
|
62
|
+
|
|
63
|
+
if filter.respond_to?(:apply) && filter.apply.present?
|
|
64
|
+
return apply_custom_filter(scope, filter.apply, value)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
case filter.type
|
|
68
|
+
when :text, :search
|
|
69
|
+
scope.where("#{filter.field} ILIKE ?", "%#{value}%")
|
|
70
|
+
when :select
|
|
71
|
+
scope.where(filter.field => value)
|
|
72
|
+
when :toggle, :boolean
|
|
73
|
+
bool_value = ActiveModel::Type::Boolean.new.cast(value)
|
|
74
|
+
scope.where(filter.field => bool_value)
|
|
75
|
+
when :number
|
|
76
|
+
scope.where(filter.field => value.to_i)
|
|
77
|
+
when :date
|
|
78
|
+
date = Date.parse(value) rescue nil
|
|
79
|
+
return scope unless date
|
|
80
|
+
scope.where(filter.field => date.all_day)
|
|
81
|
+
when :date_range
|
|
82
|
+
from_date = params["#{filter.name}_from"].presence
|
|
83
|
+
to_date = params["#{filter.name}_to"].presence
|
|
84
|
+
scope = scope.where("#{filter.field} >= ?", Date.parse(from_date)) if from_date.present?
|
|
85
|
+
scope = scope.where("#{filter.field} <= ?", Date.parse(to_date).end_of_day) if to_date.present?
|
|
86
|
+
scope
|
|
87
|
+
when :association
|
|
88
|
+
scope.where("#{filter.field}_id" => value)
|
|
89
|
+
else
|
|
90
|
+
scope
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def apply_custom_filter(scope, filter_proc, value)
|
|
95
|
+
filter_proc.arity == 2 ? filter_proc.call(scope, value) : filter_proc.call(scope, value, params)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def apply_sort(scope)
|
|
99
|
+
return scope unless index_config
|
|
100
|
+
|
|
101
|
+
sort_field = params[:sort].presence || index_config.default_sort
|
|
102
|
+
return scope unless sort_field
|
|
103
|
+
|
|
104
|
+
unless index_config.sortable_fields.include?(sort_field.to_sym)
|
|
105
|
+
sort_field = index_config.default_sort
|
|
106
|
+
end
|
|
107
|
+
return scope unless sort_field
|
|
108
|
+
|
|
109
|
+
direction_param = params[:sort_direction].presence || params[:direction].presence
|
|
110
|
+
direction =
|
|
111
|
+
if direction_param.present?
|
|
112
|
+
direction_param.to_sym == :desc ? :desc : :asc
|
|
113
|
+
else
|
|
114
|
+
(index_config.default_sort_direction || :desc).to_sym
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
scope.order(sort_field => direction)
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|