admin_suite 0.2.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/admin_suite.css +128 -0
  3. data/app/controllers/admin_suite/application_controller.rb +32 -2
  4. data/app/controllers/admin_suite/dashboard_controller.rb +59 -226
  5. data/app/helpers/admin_suite/base_helper.rb +108 -108
  6. data/app/helpers/admin_suite/panels_helper.rb +1 -1
  7. data/app/javascript/controllers/admin_suite/file_upload_controller.js +9 -9
  8. data/app/javascript/controllers/admin_suite/json_editor_controller.js +8 -8
  9. data/app/javascript/controllers/admin_suite/searchable_select_controller.js +2 -2
  10. data/app/javascript/controllers/admin_suite/tag_select_controller.js +1 -1
  11. data/app/javascript/controllers/admin_suite/toggle_switch_controller.js +1 -1
  12. data/app/views/admin_suite/dashboard/index.html.erb +6 -15
  13. data/app/views/admin_suite/panels/_cards.html.erb +6 -6
  14. data/app/views/admin_suite/panels/_chart.html.erb +12 -12
  15. data/app/views/admin_suite/panels/_health.html.erb +14 -14
  16. data/app/views/admin_suite/panels/_recent.html.erb +11 -11
  17. data/app/views/admin_suite/panels/_stat.html.erb +24 -24
  18. data/app/views/admin_suite/panels/_table.html.erb +10 -10
  19. data/app/views/admin_suite/portals/show.html.erb +1 -1
  20. data/app/views/admin_suite/resources/_form.html.erb +1 -1
  21. data/app/views/admin_suite/resources/edit.html.erb +4 -4
  22. data/app/views/admin_suite/resources/index.html.erb +23 -23
  23. data/app/views/admin_suite/resources/new.html.erb +4 -4
  24. data/app/views/admin_suite/resources/show.html.erb +17 -17
  25. data/app/views/admin_suite/shared/_form.html.erb +8 -8
  26. data/app/views/admin_suite/shared/_json_editor_field.html.erb +4 -4
  27. data/app/views/admin_suite/shared/_sidebar.html.erb +4 -4
  28. data/app/views/admin_suite/shared/_topbar.html.erb +1 -1
  29. data/app/views/layouts/admin_suite/application.html.erb +4 -4
  30. data/docs/configuration.md +56 -6
  31. data/docs/portals.md +42 -0
  32. data/lib/admin/base/action_executor.rb +69 -0
  33. data/lib/admin_suite/configuration.rb +12 -0
  34. data/lib/admin_suite/engine.rb +82 -31
  35. data/lib/admin_suite/ui/field_renderer_registry.rb +2 -2
  36. data/lib/admin_suite/ui/form_field_renderer.rb +2 -2
  37. data/lib/admin_suite/ui/show_formatter_registry.rb +5 -5
  38. data/lib/admin_suite/ui/show_value_formatter.rb +1 -1
  39. data/lib/admin_suite/version.rb +1 -1
  40. data/lib/admin_suite.rb +31 -0
  41. data/lib/generators/admin_suite/install/templates/admin_suite.rb +8 -0
  42. data/test/dummy/log/test.log +1512 -0
  43. data/test/dummy/tmp/local_secret.txt +1 -0
  44. data/test/integration/dashboard_test.rb +57 -1
  45. data/test/lib/action_executor_test.rb +172 -0
  46. data/test/lib/zeitwerk_integration_test.rb +69 -16
  47. metadata +4 -1
@@ -56,12 +56,12 @@
56
56
  <% end %>
57
57
  <% end %>
58
58
 
59
- <div class="flex flex-col lg:flex-row gap-6">
59
+ <div class="admin-suite-two-col flex flex-col md:flex-row gap-6">
60
60
  <!-- Filters Sidebar -->
61
61
  <% if resource_config.index_config&.filters_list&.any? || resource_config.index_config&.searchable_fields&.any? %>
62
- <div class="lg:w-64 flex-shrink-0">
62
+ <div class="admin-suite-two-col__sidebar admin-suite-two-col__sidebar--filters md:w-64 flex-shrink-0">
63
63
  <div class="bg-white rounded-xl border border-slate-200 p-4">
64
- <h3 class="font-medium text-slate-900 dark:text-white mb-4">Filters</h3>
64
+ <h3 class="font-medium text-slate-900 mb-4">Filters</h3>
65
65
  <%= form_with url: url_for(action: :index), method: :get,
66
66
  data: {
67
67
  turbo_frame: "resource_results",
@@ -136,7 +136,7 @@
136
136
  <% end %>
137
137
 
138
138
  <!-- Main Content -->
139
- <div class="flex-1">
139
+ <div class="flex-1 min-w-0">
140
140
  <%= turbo_frame_tag "resource_results", data: { turbo_action: "advance" } do %>
141
141
  <div class="bg-white rounded-xl border border-slate-200 overflow-hidden">
142
142
  <% if @collection.any? %>
@@ -216,25 +216,25 @@
216
216
 
217
217
  <!-- Pagination -->
218
218
  <% if @pagy.pages > 1 %>
219
- <div class="px-6 py-4 border-t border-slate-200 dark:border-slate-700">
219
+ <div class="px-6 py-4 border-t border-slate-200">
220
220
  <div class="flex items-center justify-between">
221
- <div class="text-sm text-slate-500 dark:text-slate-400">
222
- Showing <span class="font-medium text-slate-700 dark:text-slate-300"><%= @pagy.from %></span> to
223
- <span class="font-medium text-slate-700 dark:text-slate-300"><%= @pagy.to %></span> of
224
- <span class="font-medium text-slate-700 dark:text-slate-300"><%= number_with_delimiter(@pagy.count) %></span> results
221
+ <div class="text-sm text-slate-500">
222
+ Showing <span class="font-medium text-slate-700"><%= @pagy.from %></span> to
223
+ <span class="font-medium text-slate-700"><%= @pagy.to %></span> of
224
+ <span class="font-medium text-slate-700"><%= number_with_delimiter(@pagy.count) %></span> results
225
225
  </div>
226
226
 
227
227
  <nav class="flex items-center gap-1">
228
228
  <% if @pagy.prev %>
229
229
  <%= link_to url_for(params.permit!.merge(page: @pagy.prev)),
230
- class: "inline-flex items-center gap-1 px-3 py-1.5 text-sm font-medium text-slate-700 dark:text-slate-300 bg-white dark:bg-slate-700 border border-slate-300 dark:border-slate-600 rounded-lg hover:bg-slate-50 dark:hover:bg-slate-600 transition-colors" do %>
230
+ class: "inline-flex items-center gap-1 px-3 py-1.5 text-sm font-medium text-slate-700 bg-white border border-slate-300 rounded-lg hover:bg-slate-50 transition-colors" do %>
231
231
  <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
232
232
  <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
233
233
  </svg>
234
234
  Prev
235
235
  <% end %>
236
236
  <% else %>
237
- <span class="inline-flex items-center gap-1 px-3 py-1.5 text-sm font-medium text-slate-400 dark:text-slate-500 bg-slate-100 dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-lg cursor-not-allowed">
237
+ <span class="inline-flex items-center gap-1 px-3 py-1.5 text-sm font-medium text-slate-400 bg-slate-100 border border-slate-200 rounded-lg cursor-not-allowed">
238
238
  <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
239
239
  <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
240
240
  </svg>
@@ -245,10 +245,10 @@
245
245
  <div class="hidden sm:flex items-center gap-1 mx-2">
246
246
  <% @pagy.series.each do |item| %>
247
247
  <% if item == :gap %>
248
- <span class="px-2 py-1 text-sm text-slate-400 dark:text-slate-500">…</span>
248
+ <span class="px-2 py-1 text-sm text-slate-400">…</span>
249
249
  <% elsif item.is_a?(Integer) %>
250
250
  <%= link_to item, url_for(params.permit!.merge(page: item)),
251
- class: "px-3 py-1.5 text-sm font-medium text-slate-700 dark:text-slate-300 bg-white dark:bg-slate-700 border border-slate-300 dark:border-slate-600 rounded-lg hover:bg-slate-50 dark:hover:bg-slate-600 transition-colors" %>
251
+ class: "px-3 py-1.5 text-sm font-medium text-slate-700 bg-white border border-slate-300 rounded-lg hover:bg-slate-50 transition-colors" %>
252
252
  <% elsif item.is_a?(String) %>
253
253
  <span class="px-3 py-1.5 text-sm font-medium text-white rounded-lg" style="background: var(--admin-suite-primary); border: 1px solid var(--admin-suite-primary);">
254
254
  <%= item %>
@@ -257,20 +257,20 @@
257
257
  <% end %>
258
258
  </div>
259
259
 
260
- <span class="sm:hidden px-3 py-1.5 text-sm text-slate-500 dark:text-slate-400">
260
+ <span class="sm:hidden px-3 py-1.5 text-sm text-slate-500">
261
261
  Page <%= @pagy.page %> of <%= @pagy.pages %>
262
262
  </span>
263
263
 
264
264
  <% if @pagy.next %>
265
265
  <%= link_to url_for(params.permit!.merge(page: @pagy.next)),
266
- class: "inline-flex items-center gap-1 px-3 py-1.5 text-sm font-medium text-slate-700 dark:text-slate-300 bg-white dark:bg-slate-700 border border-slate-300 dark:border-slate-600 rounded-lg hover:bg-slate-50 dark:hover:bg-slate-600 transition-colors" do %>
266
+ class: "inline-flex items-center gap-1 px-3 py-1.5 text-sm font-medium text-slate-700 bg-white border border-slate-300 rounded-lg hover:bg-slate-50 transition-colors" do %>
267
267
  Next
268
268
  <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
269
269
  <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
270
270
  </svg>
271
271
  <% end %>
272
272
  <% else %>
273
- <span class="inline-flex items-center gap-1 px-3 py-1.5 text-sm font-medium text-slate-400 dark:text-slate-500 bg-slate-100 dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-lg cursor-not-allowed">
273
+ <span class="inline-flex items-center gap-1 px-3 py-1.5 text-sm font-medium text-slate-400 bg-slate-100 border border-slate-200 rounded-lg cursor-not-allowed">
274
274
  Next
275
275
  <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
276
276
  <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
@@ -281,22 +281,22 @@
281
281
  </div>
282
282
  </div>
283
283
  <% else %>
284
- <div class="px-6 py-3 border-t border-slate-200 dark:border-slate-700">
285
- <div class="text-sm text-slate-500 dark:text-slate-400">
286
- Showing <span class="font-medium text-slate-700 dark:text-slate-300"><%= @pagy.count %></span> results
284
+ <div class="px-6 py-3 border-t border-slate-200">
285
+ <div class="text-sm text-slate-500">
286
+ Showing <span class="font-medium text-slate-700"><%= @pagy.count %></span> results
287
287
  </div>
288
288
  </div>
289
289
  <% end %>
290
290
  <% else %>
291
291
  <div class="p-12 text-center">
292
- <div class="inline-flex items-center justify-center w-12 h-12 rounded-full bg-slate-100 dark:bg-slate-900 mb-4">
292
+ <div class="inline-flex items-center justify-center w-12 h-12 rounded-full bg-slate-100 mb-4">
293
293
  <svg class="w-6 h-6 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
294
294
  <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"/>
295
295
  </svg>
296
296
  </div>
297
- <h3 class="text-lg font-medium text-slate-900 dark:text-white">No <%= resource_config.human_name_plural.downcase %> found</h3>
297
+ <h3 class="text-lg font-medium text-slate-900">No <%= resource_config.human_name_plural.downcase %> found</h3>
298
298
  <% if has_new_route %>
299
- <p class="text-slate-500 dark:text-slate-400 mt-1">Get started by creating your first one.</p>
299
+ <p class="text-slate-500 mt-1">Get started by creating your first one.</p>
300
300
  <%= link_to url_for(action: :new),
301
301
  class: theme_btn_primary_class + " mt-4",
302
302
  data: { turbo_frame: "_top" } do %>
@@ -304,7 +304,7 @@
304
304
  New <%= resource_config.human_name %>
305
305
  <% end %>
306
306
  <% else %>
307
- <p class="text-slate-500 dark:text-slate-400 mt-1">No records have been created yet.</p>
307
+ <p class="text-slate-500 mt-1">No records have been created yet.</p>
308
308
  <% end %>
309
309
  </div>
310
310
  <% end %>
@@ -3,18 +3,18 @@
3
3
  <div class="px-4 sm:px-6 lg:px-8 max-w-3xl mx-auto">
4
4
  <!-- Header -->
5
5
  <div class="mb-6">
6
- <nav class="flex items-center gap-2 text-sm text-slate-500 dark:text-slate-400 mb-2">
6
+ <nav class="flex items-center gap-2 text-sm text-slate-500 mb-2">
7
7
  <%= link_to "Dashboard", root_path, class: theme_link_hover_text_class %>
8
8
  <%= admin_suite_icon("chevron-right", class: "w-4 h-4") %>
9
9
  <%= link_to resource_config.human_name_plural, url_for(action: :index), class: theme_link_hover_text_class %>
10
10
  <%= admin_suite_icon("chevron-right", class: "w-4 h-4") %>
11
- <span class="text-slate-900 dark:text-white">New</span>
11
+ <span class="text-slate-900">New</span>
12
12
  </nav>
13
- <h1 class="text-2xl font-bold text-slate-900 dark:text-white">New <%= resource_config.human_name %></h1>
13
+ <h1 class="text-2xl font-bold text-slate-900">New <%= resource_config.human_name %></h1>
14
14
  </div>
15
15
 
16
16
  <!-- Form -->
17
- <div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 overflow-hidden">
17
+ <div class="bg-white rounded-xl border border-slate-200 overflow-hidden">
18
18
  <div class="p-6">
19
19
  <%= render "admin_suite/shared/form", resource: @resource %>
20
20
  </div>
@@ -26,23 +26,23 @@
26
26
  <div class="px-4 sm:px-6 lg:px-8 max-w-7xl mx-auto">
27
27
  <!-- Header -->
28
28
  <div class="mb-6">
29
- <nav class="flex items-center gap-2 text-sm text-slate-500 dark:text-slate-400 mb-2">
29
+ <nav class="flex items-center gap-2 text-sm text-slate-500 mb-2">
30
30
  <%= link_to "Dashboard", root_path, class: theme_link_hover_text_class %>
31
31
  <%= admin_suite_icon("chevron-right", class: "w-4 h-4") %>
32
32
  <%= link_to resource_config.human_name_plural, url_for(action: :index), class: theme_link_hover_text_class %>
33
33
  <%= admin_suite_icon("chevron-right", class: "w-4 h-4") %>
34
- <span class="text-slate-900 dark:text-white">
34
+ <span class="text-slate-900">
35
35
  <%= resource.respond_to?(:name) ? resource.name.to_s.truncate(30) : "##{resource.id}" %>
36
36
  </span>
37
37
  </nav>
38
38
 
39
39
  <div class="flex items-center justify-between">
40
- <h1 class="text-2xl font-bold text-slate-900 dark:text-white">
40
+ <h1 class="text-2xl font-bold text-slate-900">
41
41
  <%= resource.respond_to?(:name) ? resource.name : "#{resource_config.human_name} ##{resource.id}" %>
42
42
  </h1>
43
43
  <div class="flex items-center gap-2">
44
44
  <% if has_edit_route %>
45
- <%= link_to url_for(action: :edit, id: resource.to_param), class: "inline-flex items-center gap-2 px-4 py-2 bg-slate-100 hover:bg-slate-200 dark:bg-slate-700 dark:hover:bg-slate-600 text-slate-900 dark:text-white text-sm font-medium rounded-lg transition-colors" do %>
45
+ <%= link_to url_for(action: :edit, id: resource.to_param), class: "inline-flex items-center gap-2 px-4 py-2 bg-slate-100 hover:bg-slate-200 text-slate-900 text-sm font-medium rounded-lg transition-colors" do %>
46
46
  <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
47
47
  <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
48
48
  </svg>
@@ -128,24 +128,24 @@
128
128
  <% end %>
129
129
 
130
130
  <!-- Two Column Layout -->
131
- <div class="flex flex-col lg:flex-row gap-6">
131
+ <div class="admin-suite-two-col flex flex-col md:flex-row gap-6">
132
132
  <!-- Sidebar (Left Column) -->
133
133
  <% if sidebar_sections.any? %>
134
- <div class="lg:w-80 flex-shrink-0 space-y-6">
134
+ <div class="admin-suite-two-col__sidebar admin-suite-two-col__sidebar--details md:w-80 flex-shrink-0 space-y-6">
135
135
  <% sidebar_sections.each do |section| %>
136
136
  <%= render_show_section(resource, section, :sidebar) %>
137
137
  <% end %>
138
138
  </div>
139
139
  <% else %>
140
- <div class="lg:w-80 flex-shrink-0 space-y-6">
141
- <div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 overflow-hidden">
142
- <div class="px-4 py-3 border-b border-slate-200 dark:border-slate-700 bg-slate-50 dark:bg-slate-900/50">
143
- <h3 class="font-medium text-slate-900 dark:text-white text-sm">Details</h3>
140
+ <div class="admin-suite-two-col__sidebar admin-suite-two-col__sidebar--details md:w-80 flex-shrink-0 space-y-6">
141
+ <div class="bg-white rounded-xl border border-slate-200 overflow-hidden">
142
+ <div class="px-4 py-3 border-b border-slate-200 bg-slate-50">
143
+ <h3 class="font-medium text-slate-900 text-sm">Details</h3>
144
144
  </div>
145
145
  <div class="p-4 space-y-3">
146
146
  <div class="flex justify-between items-start">
147
- <span class="text-xs font-medium text-slate-500 dark:text-slate-400 uppercase tracking-wider">ID</span>
148
- <span class="text-sm font-mono text-slate-900 dark:text-white"><%= resource.id %></span>
147
+ <span class="text-xs font-medium text-slate-500 uppercase tracking-wider">ID</span>
148
+ <span class="text-sm font-mono text-slate-900"><%= resource.id %></span>
149
149
  </div>
150
150
  </div>
151
151
  </div>
@@ -159,18 +159,18 @@
159
159
  <%= render_show_section(resource, section, :main) %>
160
160
  <% end %>
161
161
  <% else %>
162
- <div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 overflow-hidden">
163
- <div class="px-6 py-4 border-b border-slate-200 dark:border-slate-700 bg-slate-50 dark:bg-slate-900/50">
164
- <h3 class="font-medium text-slate-900 dark:text-white">All Attributes</h3>
162
+ <div class="bg-white rounded-xl border border-slate-200 overflow-hidden">
163
+ <div class="px-6 py-4 border-b border-slate-200 bg-slate-50">
164
+ <h3 class="font-medium text-slate-900">All Attributes</h3>
165
165
  </div>
166
166
  <div class="p-6">
167
167
  <dl class="space-y-6">
168
168
  <% resource.attributes.except("id", "created_at", "updated_at").each do |attr, _value| %>
169
169
  <div>
170
- <dt class="text-sm font-medium text-slate-500 dark:text-slate-400 mb-2">
170
+ <dt class="text-sm font-medium text-slate-500 mb-2">
171
171
  <%= attr.humanize %>
172
172
  </dt>
173
- <dd class="text-sm text-slate-900 dark:text-white">
173
+ <dd class="text-sm text-slate-900">
174
174
  <%= format_show_value(resource, attr) %>
175
175
  </dd>
176
176
  </div>
@@ -2,16 +2,16 @@
2
2
 
3
3
  <%= form_with model: resource, url: resource.new_record? ? url_for(action: :create) : url_for(action: :update, id: resource.to_param), local: true, class: "space-y-6" do |f| %>
4
4
  <% if resource.errors.any? %>
5
- <div class="p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
5
+ <div class="p-4 bg-red-50 border border-red-200 rounded-lg">
6
6
  <div class="flex items-start gap-3">
7
7
  <svg class="w-5 h-5 text-red-500 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
8
8
  <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
9
9
  </svg>
10
10
  <div>
11
- <h3 class="text-sm font-medium text-red-800 dark:text-red-200">
11
+ <h3 class="text-sm font-medium text-red-800">
12
12
  <%= pluralize(resource.errors.count, "error") %> prohibited this record from being saved:
13
13
  </h3>
14
- <ul class="mt-2 text-sm text-red-700 dark:text-red-300 list-disc list-inside">
14
+ <ul class="mt-2 text-sm text-red-700 list-disc list-inside">
15
15
  <% resource.errors.full_messages.each do |message| %>
16
16
  <li><%= message %></li>
17
17
  <% end %>
@@ -24,10 +24,10 @@
24
24
  <% resource_config.form_config&.fields_list&.each do |field| %>
25
25
  <% case field %>
26
26
  <% when Admin::Base::Resource::SectionDefinition %>
27
- <div class="border-t border-slate-200 dark:border-slate-700 pt-6 mt-6">
28
- <h3 class="text-lg font-medium text-slate-900 dark:text-white mb-1"><%= field.title %></h3>
27
+ <div class="border-t border-slate-200 pt-6 mt-6">
28
+ <h3 class="text-lg font-medium text-slate-900 mb-1"><%= field.title %></h3>
29
29
  <% if field.description.present? %>
30
- <p class="text-sm text-slate-500 dark:text-slate-400 mb-4"><%= field.description %></p>
30
+ <p class="text-sm text-slate-500 mb-4"><%= field.description %></p>
31
31
  <% end %>
32
32
  </div>
33
33
  <% when Admin::Base::Resource::SectionEnd %>
@@ -51,8 +51,8 @@
51
51
  <% end %>
52
52
  <% end %>
53
53
 
54
- <div class="flex items-center justify-end gap-3 pt-6 border-t border-slate-200 dark:border-slate-700">
55
- <%= link_to "Cancel", resource.new_record? ? url_for(action: :index) : url_for(action: :show, id: resource.to_param), class: "px-4 py-2 text-sm font-medium text-slate-700 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-700 rounded-lg transition-colors" %>
54
+ <div class="flex items-center justify-end gap-3 pt-6 border-t border-slate-200">
55
+ <%= link_to "Cancel", resource.new_record? ? url_for(action: :index) : url_for(action: :show, id: resource.to_param), class: "px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-100 rounded-lg transition-colors" %>
56
56
  <%= f.button class: theme_btn_primary_small_class, data: { disable_with: "Saving..." } do %>
57
57
  <%= resource.new_record? ? "Create #{resource_config.human_name}" : "Save Changes" %>
58
58
  <% end %>
@@ -18,9 +18,9 @@
18
18
 
19
19
  <div data-controller="admin-suite--json-editor">
20
20
  <% if field.help.present? %>
21
- <p class="text-sm text-slate-500 dark:text-slate-400 mb-2"><%= field.help %></p>
21
+ <p class="text-sm text-slate-500 mb-2"><%= field.help %></p>
22
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>
23
+ <p class="text-sm text-slate-500 mb-2">Enter valid JSON. Use the Format button to pretty-print.</p>
24
24
  <% end %>
25
25
 
26
26
  <div class="relative">
@@ -38,7 +38,7 @@
38
38
  <button
39
39
  type="button"
40
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"
41
+ class="px-3 py-1.5 text-xs font-medium text-slate-700 bg-slate-100 hover:bg-slate-200 rounded transition-colors"
42
42
  >
43
43
  Format JSON
44
44
  </button>
@@ -47,6 +47,6 @@
47
47
 
48
48
  <div
49
49
  data-admin-suite--json-editor-target="error"
50
- class="hidden mt-2 text-sm text-red-600 dark:text-red-400"
50
+ class="hidden mt-2 text-sm text-red-600"
51
51
  ></div>
52
52
  </div>
@@ -44,13 +44,13 @@
44
44
  <!-- Portal Navigation -->
45
45
  <nav class="flex-1 overflow-y-auto px-3 py-4 space-y-2">
46
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) %>
47
+ <% portal_home = portal_path(portal: portal_key).to_s %>
48
+ <% portal_prefix = portal_home.sub(%r{/*\z}, "") %>
49
+ <% active = portal_prefix.present? && (request.path == portal_prefix || request.path.start_with?("#{portal_prefix}/")) %>
50
50
  <% color = portal_color(portal_key) %>
51
51
 
52
52
  <div>
53
- <%= link_to (portal_home || root_path),
53
+ <%= link_to (portal_prefix.presence || root_path),
54
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
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
56
  <%= portal_icon(portal_key, class: "admin-suite-portal-icon w-4 h-4") %>
@@ -6,7 +6,7 @@
6
6
  <div class="flex items-center gap-4">
7
7
  <!-- Mobile menu button -->
8
8
  <button type="button"
9
- class="lg:hidden p-2 rounded-lg text-slate-500 hover:bg-slate-100"
9
+ class="admin-suite-mobile-menu-button lg:hidden p-2 rounded-lg text-slate-500 hover:bg-slate-100"
10
10
  data-action="click->admin-suite--sidebar#toggle">
11
11
  <svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
12
12
  <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/>
@@ -41,24 +41,24 @@
41
41
  <body class="admin-suite bg-slate-50">
42
42
  <div class="flex h-screen overflow-hidden" data-controller="admin-suite--sidebar">
43
43
  <!-- Sidebar -->
44
- <div class="hidden lg:flex lg:flex-shrink-0">
44
+ <div class="admin-suite-desktop-sidebar lg:flex lg:flex-shrink-0">
45
45
  <%= render "admin_suite/shared/sidebar" %>
46
46
  </div>
47
47
 
48
48
  <!-- Mobile sidebar overlay -->
49
49
  <div data-admin-suite--sidebar-target="overlay"
50
- class="hidden fixed inset-0 z-40 bg-slate-600 bg-opacity-75 lg:hidden"
50
+ class="admin-suite-mobile-overlay hidden fixed inset-0 z-40 bg-slate-600 bg-opacity-75 lg:hidden"
51
51
  data-action="click->admin-suite--sidebar#close">
52
52
  </div>
53
53
 
54
54
  <!-- Mobile sidebar -->
55
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">
56
+ class="admin-suite-mobile-sidebar hidden fixed inset-y-0 left-0 z-50 w-72 transform transition-transform duration-300 ease-in-out lg:hidden">
57
57
  <%= render "admin_suite/shared/sidebar" %>
58
58
  </div>
59
59
 
60
60
  <!-- Main content area -->
61
- <div class="flex flex-col flex-1 overflow-hidden">
61
+ <div class="admin-suite-main flex flex-col flex-1 overflow-hidden">
62
62
  <!-- Top bar -->
63
63
  <%= render "admin_suite/shared/topbar" %>
64
64
 
@@ -32,16 +32,32 @@ These are the defaults in `AdminSuite::Configuration` / `AdminSuite::Engine`:
32
32
  - `resource_globs`: defaults to:
33
33
  - `Rails.root/config/admin_suite/resources/*.rb`
34
34
  - `Rails.root/app/admin/resources/*.rb`
35
+ - `action_globs`: defaults to:
36
+ - `Rails.root/config/admin_suite/actions/*.rb`
37
+ - `Rails.root/app/admin/actions/*.rb`
35
38
  - `portal_globs`: defaults to:
36
39
  - `Rails.root/config/admin_suite/portals/*.rb`
37
40
  - `Rails.root/app/admin/portals/*.rb`
38
41
  - `Rails.root/app/admin_suite/portals/*.rb`
39
-
40
- Note: portal files are DSL side-effects (they don't define constants). If your host app
41
- autoloads `app/admin` as `Admin::*` with Zeitwerk, AdminSuite will ignore
42
- `app/admin/portals` for Zeitwerk to prevent eager-load `Zeitwerk::NameError`s.
43
- We recommend placing portal DSL files under `config/admin_suite/portals` or
44
- `app/admin_suite/portals` when possible.
42
+ - `dashboard_globs`: defaults to:
43
+ - `Rails.root/config/admin_suite/dashboard.rb`
44
+ - `Rails.root/config/admin_suite/dashboard/*.rb`
45
+ - `Rails.root/app/admin_suite/dashboard.rb`
46
+ - `Rails.root/app/admin_suite/dashboard/*.rb`
47
+
48
+ Note: AdminSuite definition files (resources, actions, portals) often don't follow
49
+ Zeitwerk's path-to-constant naming conventions. To prevent eager-load `Zeitwerk::NameError`s
50
+ in production, the engine only configures Zeitwerk to ignore these directories and load them via globs instead:
51
+ - `app/admin_suite`
52
+ - `app/admin/portals` (when portal DSL usage is detected)
53
+
54
+ Other `app/admin/*` directories (such as `app/admin/resources`, `app/admin/actions`, and `app/admin/base`) are
55
+ not ignored by default and may be treated as normal Zeitwerk autoload paths if they are added to the loader
56
+ (for example, via `loader.push_dir("app/admin")` in the host app). Do not rely on these directories being
57
+ ignored for autoloading; instead, keep files there Zeitwerk-compatible.
58
+
59
+ We recommend placing non-Zeitwerk-compatible definition files under `config/admin_suite/*` or `app/admin_suite/*`
60
+ for clearer separation from standard Rails autoloading.
45
61
  - `portals`: default portal metadata for `:ops`, `:email`, `:ai`, `:assistant`
46
62
  - `custom_renderers`: `{}`
47
63
  - `icon_renderer`: `nil` (uses lucide-rails by default when available)
@@ -95,12 +111,46 @@ config.resource_globs = [
95
111
  ]
96
112
  ```
97
113
 
114
+ ### `action_globs`
115
+
116
+ Where AdminSuite should load action handler files from (files that define custom action handlers, typically subclasses of `Admin::Base::ActionHandler`).
117
+
118
+ - **Type**: `Array<String>`
119
+
120
+ Example:
121
+
122
+ ```ruby
123
+ config.action_globs = [
124
+ Rails.root.join("app/admin/actions/**/*.rb").to_s
125
+ ]
126
+ ```
127
+
98
128
  ### `portal_globs`
99
129
 
100
130
  Where AdminSuite should load portal definition files from (files typically call `AdminSuite.portal(:key) { ... }`).
101
131
 
102
132
  - **Type**: `Array<String>`
103
133
 
134
+ ### `dashboard_globs`
135
+
136
+ Where AdminSuite should load the root dashboard definition file(s) from (files typically call `AdminSuite.root_dashboard { ... }`).
137
+
138
+ - **Type**: `Array<String>`
139
+
140
+ ### `root_dashboard_title`
141
+
142
+ Optional title shown on the root dashboard.
143
+
144
+ - **Type**: `String`, `Proc`, or `nil`
145
+ - **Proc signature**: `->(controller) { "Admin Suite" }`
146
+
147
+ ### `root_dashboard_description`
148
+
149
+ Optional description shown on the root dashboard.
150
+
151
+ - **Type**: `String`, `Proc`, or `nil`
152
+ - **Proc signature**: `->(controller) { "..." }`
153
+
104
154
  ### `portals`
105
155
 
106
156
  Portal metadata used for navigation (label/icon/color/order). This is separate from the portal DSL and can be used alone.
data/docs/portals.md CHANGED
@@ -96,3 +96,45 @@ Example:
96
96
  - `/internal/admin/ops`
97
97
  - `/internal/admin/ai`
98
98
 
99
+ ## Root dashboard (`AdminSuite.root_dashboard`)
100
+
101
+ The engine root (`/`, relative to the mount path) renders a default dashboard (portal cards + basic stats).
102
+
103
+ To customize it, create a dashboard definition file (loaded from `AdminSuite.config.dashboard_globs`, which defaults to paths like `config/admin_suite/dashboard.rb` and `app/admin_suite/dashboard.rb`):
104
+
105
+ ```ruby
106
+ # config/admin_suite/dashboard.rb
107
+ AdminSuite.configure do |config|
108
+ config.root_dashboard_title = "Developer Portal"
109
+ config.root_dashboard_description = "Internal tools for managing application resources."
110
+ end
111
+
112
+ AdminSuite.root_dashboard do
113
+ row do
114
+ cards_panel "Portals",
115
+ span: 12,
116
+ variant: :portals,
117
+ resources: ->(view) do
118
+ view.navigation_items
119
+ .sort_by { |(_k, meta)| (meta[:order] || 100).to_i }
120
+ .map do |portal_key, portal|
121
+ {
122
+ key: portal_key,
123
+ label: portal[:label] || portal_key.to_s.humanize,
124
+ description: portal[:description],
125
+ color: view.portal_color(portal_key),
126
+ icon: portal[:icon],
127
+ path: view.portal_path(portal: portal_key),
128
+ count: (portal[:sections] || {}).values.sum { |s| Array(s[:items]).size }
129
+ }
130
+ end
131
+ end
132
+ end
133
+
134
+ row do
135
+ stat_panel "Portals", ->(view) { view.navigation_items.keys.count }, span: 6, variant: :mini, color: :slate
136
+ stat_panel "Resources", -> { Admin::Base::Resource.registered_resources.count }, span: 6, variant: :mini, color: :slate
137
+ end
138
+ end
139
+ ```
140
+
@@ -12,6 +12,13 @@ module Admin
12
12
  def failure? = !success
13
13
  end
14
14
 
15
+ # Track whether action handlers have been loaded to avoid repeated expensive globs
16
+ @handlers_loaded = false
17
+
18
+ class << self
19
+ attr_accessor :handlers_loaded
20
+ end
21
+
15
22
  def initialize(resource_class, action_name, actor)
16
23
  @resource_class = resource_class
17
24
  @action_name = action_name
@@ -116,6 +123,17 @@ module Admin
116
123
  return resolved if resolved
117
124
  end
118
125
 
126
+ handler_name = "#{resource_class.resource_name.camelize}#{action.name.to_s.camelize}Action"
127
+ handler_constant = "Admin::Actions::#{handler_name}"
128
+ handler_constant.constantize
129
+ rescue NameError
130
+ # In many host apps, action handlers live under `app/admin/actions/**`.
131
+ # Rails treats `app/admin` as a Zeitwerk root, which means Zeitwerk expects
132
+ # top-level constants (e.g. `Actions::Foo`) unless the host configures
133
+ # a namespace mapping. AdminSuite avoids requiring host Zeitwerk setup by
134
+ # loading handler files via `AdminSuite.config.action_globs` when needed.
135
+ load_action_handlers_for_admin_suite!
136
+
119
137
  handler_name = "#{resource_class.resource_name.camelize}#{action.name.to_s.camelize}Action"
120
138
  "Admin::Actions::#{handler_name}".constantize
121
139
  rescue NameError
@@ -150,6 +168,57 @@ module Admin
150
168
  rescue StandardError
151
169
  nil
152
170
  end
171
+
172
+ def load_action_handlers_for_admin_suite!
173
+ return unless defined?(AdminSuite)
174
+
175
+ # Track whether we've already loaded handlers to avoid expensive repeated globs.
176
+ # In development, this flag is reset by the Rails reloader (see engine.rb).
177
+ # In production/test, it persists for the process lifetime.
178
+ return if self.class.handlers_loaded
179
+
180
+ files = Array(AdminSuite.config.action_globs).flat_map { |g| Dir[g] }.uniq
181
+
182
+ # Set the flag even if no files found - we've done the glob and shouldn't repeat it
183
+ if files.empty?
184
+ self.class.handlers_loaded = true
185
+ return
186
+ end
187
+
188
+ files.each do |file|
189
+ begin
190
+ if Rails.env.development?
191
+ load file
192
+ else
193
+ require file
194
+ end
195
+ rescue StandardError, ScriptError => e
196
+ log_action_handler_load_error(file, e)
197
+
198
+ # Fail fast in dev/test so broken handler files are immediately discoverable.
199
+ raise if Rails.env.development? || Rails.env.test?
200
+ end
201
+ end
202
+
203
+ # We attempted to load the configured handlers. Avoid repeating expensive globs
204
+ # and file loads for the rest of the process lifetime.
205
+ self.class.handlers_loaded = true
206
+ end
207
+
208
+ def log_action_handler_load_error(file, error)
209
+ message = "[AdminSuite] Failed to load action handler file #{file}: #{error.class}: #{error.message}"
210
+
211
+ if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
212
+ Rails.logger.error(message)
213
+
214
+ backtrace = Array(error.backtrace).take(20).join("\n")
215
+ Rails.logger.error(backtrace) unless backtrace.empty?
216
+ else
217
+ warn(message)
218
+ end
219
+ rescue StandardError
220
+ nil
221
+ end
153
222
  end
154
223
  end
155
224
  end
@@ -7,7 +7,9 @@ module AdminSuite
7
7
  :current_actor,
8
8
  :authorize,
9
9
  :resource_globs,
10
+ :action_globs,
10
11
  :portal_globs,
12
+ :dashboard_globs,
11
13
  :portals,
12
14
  :custom_renderers,
13
15
  :icon_renderer,
@@ -17,6 +19,10 @@ module AdminSuite
17
19
  :theme,
18
20
  :host_stylesheet,
19
21
  :tailwind_cdn,
22
+ :root_dashboard_title,
23
+ :root_dashboard_description,
24
+ :root_dashboard_definition,
25
+ :root_dashboard_loaded,
20
26
  :on_action_executed,
21
27
  :resolve_action_handler
22
28
 
@@ -25,7 +31,9 @@ module AdminSuite
25
31
  @current_actor = nil
26
32
  @authorize = nil
27
33
  @resource_globs = []
34
+ @action_globs = []
28
35
  @portal_globs = []
36
+ @dashboard_globs = []
29
37
  @portals = {}
30
38
  @custom_renderers = {}
31
39
  @icon_renderer = nil
@@ -35,6 +43,10 @@ module AdminSuite
35
43
  @theme = { primary: :indigo, secondary: :purple }
36
44
  @host_stylesheet = nil
37
45
  @tailwind_cdn = true
46
+ @root_dashboard_title = nil
47
+ @root_dashboard_description = nil
48
+ @root_dashboard_definition = nil
49
+ @root_dashboard_loaded = false
38
50
  @on_action_executed = nil
39
51
  @resolve_action_handler = nil
40
52
  end