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
@@ -13,41 +13,65 @@ module AdminSuite
13
13
  end
14
14
  end
15
15
 
16
- initializer "admin_suite.host_dsl_ignore" do
17
- # Host apps sometimes store AdminSuite DSL files under `app/admin_suite/**`
18
- # and/or `app/admin/portals/**`.
16
+ initializer "admin_suite.host_dsl_ignore", before: :setup_main_autoloader do |app|
17
+ # Host apps may store AdminSuite DSL files under `app/admin_suite/**` and
18
+ # `app/admin/portals/**`.
19
19
  #
20
- # These are *side-effect* DSL files (e.g. `AdminSuite.portal :ops do ... end`),
21
- # not constant definitions. If a host app adds those dirs to Zeitwerk (common
22
- # when autoloading `app/admin` as `Admin::*`), eager loading will crash with:
23
- # expected file ... to define constant ...
24
- #
25
- # So we proactively ignore these directories for all Zeitwerk loaders.
26
- host_dsl_dirs = [ Rails.root.join("app/admin_suite") ]
27
-
28
- # `app/admin/portals` is a more common location in host apps and could also
29
- # contain real constants. Only ignore it if it appears to contain AdminSuite
30
- # portal DSL definitions.
31
- host_admin_portals_dir = Rails.root.join("app/admin/portals")
32
- if host_admin_portals_dir.exist?
33
- portal_files = Dir[host_admin_portals_dir.join("**/*.rb").to_s]
34
- contains_admin_suite_portals =
35
- portal_files.any? do |file|
36
- content = File.binread(file)
37
- content = content.encode("UTF-8", invalid: :replace, undef: :replace, replace: "")
38
- portal_dsl_pattern = /(::)?AdminSuite\s*\.\s*portal\b/
39
- portal_dsl_pattern.match?(content)
40
- rescue StandardError
41
- false
42
- end
43
-
44
- host_dsl_dirs << host_admin_portals_dir if contains_admin_suite_portals
20
+ # These are side-effect DSL files (they do not define constants), so Zeitwerk
21
+ # must ignore them to avoid eager-load `Zeitwerk::NameError`s in production.
22
+
23
+ admin_suite_app_dir = Rails.root.join("app/admin_suite")
24
+ admin_dir = Rails.root.join("app/admin")
25
+ admin_portals_dir = Rails.root.join("app/admin/portals")
26
+
27
+ # If the host uses `Admin::*` constants inside `app/admin/**`, Rails' default
28
+ # autoload root (`app/admin`) would expect top-level constants like
29
+ # `Resources::UserResource`. We fix that by mapping `app/admin` to `Admin`.
30
+ # This avoids requiring host apps to add their own Zeitwerk initializer.
31
+ if admin_dir.exist? && self.class.host_admin_namespace_files?(admin_dir)
32
+ admin_dir_s = admin_dir.to_s
33
+ app.config.autoload_paths.delete(admin_dir_s)
34
+ app.config.eager_load_paths.delete(admin_dir_s)
35
+
36
+ # Ensure `Admin` exists so Zeitwerk can use it as a namespace.
37
+ module ::Admin; end
38
+
39
+ Rails.autoloaders.main.push_dir(admin_dir, namespace: ::Admin)
45
40
  end
46
41
 
47
42
  Rails.autoloaders.each do |loader|
48
- host_dsl_dirs.each do |dir|
49
- loader.ignore(dir) if dir.exist?
50
- end
43
+ loader.ignore(admin_suite_app_dir) if admin_suite_app_dir.exist?
44
+
45
+ next unless admin_portals_dir.exist?
46
+
47
+ loader.ignore(admin_portals_dir) if self.class.contains_admin_suite_portal_dsl?(admin_portals_dir)
48
+ end
49
+ end
50
+
51
+ def self.host_admin_namespace_files?(admin_dir)
52
+ # True if any file under app/admin appears to define `Admin::*` constants.
53
+ Dir[admin_dir.join("**/*.rb").to_s].any? do |file|
54
+ next false if file.include?("/portals/")
55
+
56
+ content = File.binread(file)
57
+ content = content.encode("UTF-8", invalid: :replace, undef: :replace, replace: "")
58
+
59
+ content.match?(/\b(module|class)\s+Admin\b/) ||
60
+ content.match?(/\b(module|class)\s+Admin::/)
61
+ rescue StandardError
62
+ false
63
+ end
64
+ end
65
+
66
+ def self.contains_admin_suite_portal_dsl?(admin_portals_dir)
67
+ portal_files = Dir[admin_portals_dir.join("**/*.rb").to_s]
68
+ portal_files.any? do |file|
69
+ content = File.binread(file)
70
+ content = content.encode("UTF-8", invalid: :replace, undef: :replace, replace: "")
71
+ portal_dsl_pattern = /(::)?AdminSuite\s*\.\s*portal\b/
72
+ portal_dsl_pattern.match?(content)
73
+ rescue StandardError
74
+ false
51
75
  end
52
76
  end
53
77
 
@@ -59,6 +83,17 @@ module AdminSuite
59
83
  require "admin/base/action_handler"
60
84
  end
61
85
 
86
+ initializer "admin_suite.reloader" do |app|
87
+ # Reset the handlers_loaded flag in development so handlers are reloaded
88
+ # when code changes. This ensures the expensive glob operation happens at
89
+ # most once per request (or code reload) rather than on every NameError.
90
+ if Rails.env.development?
91
+ app.reloader.to_prepare do
92
+ Admin::Base::ActionExecutor.handlers_loaded = false
93
+ end
94
+ end
95
+ end
96
+
62
97
  initializer "admin_suite.watchable_dirs" do |app|
63
98
  next unless Rails.env.development?
64
99
 
@@ -91,6 +126,13 @@ module AdminSuite
91
126
  ]
92
127
  end
93
128
 
129
+ if config.action_globs.blank?
130
+ config.action_globs = [
131
+ Rails.root.join("config/admin_suite/actions/*.rb").to_s,
132
+ Rails.root.join("app/admin/actions/*.rb").to_s
133
+ ]
134
+ end
135
+
94
136
  if config.portal_globs.blank?
95
137
  config.portal_globs = [
96
138
  Rails.root.join("config/admin_suite/portals/*.rb").to_s,
@@ -99,6 +141,15 @@ module AdminSuite
99
141
  ]
100
142
  end
101
143
 
144
+ if config.dashboard_globs.blank?
145
+ config.dashboard_globs = [
146
+ Rails.root.join("config/admin_suite/dashboard.rb").to_s,
147
+ Rails.root.join("config/admin_suite/dashboard/*.rb").to_s,
148
+ Rails.root.join("app/admin_suite/dashboard.rb").to_s,
149
+ Rails.root.join("app/admin_suite/dashboard/*.rb").to_s
150
+ ]
151
+ end
152
+
102
153
  config.portals = {
103
154
  ops: { label: "Ops Portal", icon: "settings", color: :amber, order: 10 },
104
155
  email: { label: "Email Portal", icon: "inbox", color: :emerald, order: 20 },
@@ -75,11 +75,11 @@ AdminSuite::UI::FieldRendererRegistry.register(:attachment) do |view, f, field,
75
75
  end
76
76
 
77
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")
78
+ f.rich_text_area(field.name, class: "prose max-w-none")
79
79
  end
80
80
 
81
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")
82
+ f.rich_text_area(field.name, class: "prose max-w-none")
83
83
  end
84
84
 
85
85
  AdminSuite::UI::FieldRendererRegistry.register(:markdown) do |_view, f, field, resource, field_class|
@@ -38,8 +38,8 @@ module AdminSuite
38
38
 
39
39
  concat(field_html)
40
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?
41
+ concat(content_tag(:p, field.help, class: "mt-1 text-sm text-slate-500")) if field.help.present?
42
+ concat(content_tag(:p, resource.errors[field.name].first, class: "mt-1 text-sm text-red-600")) if resource.errors[field.name].any?
43
43
  end)
44
44
  end
45
45
  end
@@ -40,7 +40,7 @@ end
40
40
  AdminSuite::UI::ShowFormatterRegistry.register_class(TrueClass) do |_value, view, _record, _field|
41
41
  view.content_tag(:span, class: "inline-flex items-center gap-1") do
42
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"))
43
+ view.concat(view.content_tag(:span, "Yes", class: "text-green-600 font-medium"))
44
44
  end
45
45
  end
46
46
 
@@ -54,14 +54,14 @@ end
54
54
  AdminSuite::UI::ShowFormatterRegistry.register_class(Time) do |value, view, _record, _field|
55
55
  view.content_tag(:span, class: "inline-flex items-center gap-2") do
56
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"))
57
+ view.concat(view.content_tag(:span, "(#{view.time_ago_in_words(value)} ago)", class: "text-slate-500 text-xs"))
58
58
  end
59
59
  end
60
60
 
61
61
  AdminSuite::UI::ShowFormatterRegistry.register_class(DateTime) do |value, view, _record, _field|
62
62
  view.content_tag(:span, class: "inline-flex items-center gap-2") do
63
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"))
64
+ view.concat(view.content_tag(:span, "(#{view.time_ago_in_words(value)} ago)", class: "text-slate-500 text-xs"))
65
65
  end
66
66
  end
67
67
 
@@ -72,7 +72,7 @@ end
72
72
  if defined?(ActiveRecord::Base)
73
73
  AdminSuite::UI::ShowFormatterRegistry.register_class(ActiveRecord::Base) do |value, view, _record, _field|
74
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")
75
+ view.content_tag(:span, link_text, class: "text-indigo-600")
76
76
  end
77
77
  end
78
78
 
@@ -88,7 +88,7 @@ AdminSuite::UI::ShowFormatterRegistry.register_class(Array) do |value, view, _re
88
88
  else
89
89
  view.content_tag(:div, class: "flex flex-wrap gap-1") do
90
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"))
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 text-slate-700"))
92
92
  end
93
93
  end
94
94
  end
@@ -19,7 +19,7 @@ module AdminSuite
19
19
  else
20
20
  simple_format(value.to_s)
21
21
  end
22
- return content_tag(:div, rendered, class: "prose dark:prose-invert max-w-none")
22
+ return content_tag(:div, rendered, class: "prose max-w-none")
23
23
  when :json
24
24
  begin
25
25
  parsed =
@@ -2,7 +2,7 @@
2
2
 
3
3
  module AdminSuite
4
4
  module Version
5
- VERSION = "0.2.0"
5
+ VERSION = "0.2.2"
6
6
  end
7
7
 
8
8
  # Backward-compatible constant.
data/lib/admin_suite.rb CHANGED
@@ -53,5 +53,36 @@ module AdminSuite
53
53
  def portal_definitions
54
54
  PortalRegistry.all
55
55
  end
56
+
57
+ # Defines (or updates) the root dashboard shown at the engine root (`/`).
58
+ #
59
+ # This uses the same dashboard DSL as portal dashboards.
60
+ #
61
+ # Host apps typically place these in:
62
+ # - `config/admin_suite/dashboard.rb` (recommended; not a Zeitwerk autoload path)
63
+ # - `app/admin_suite/dashboard.rb` (supported; AdminSuite ignores for Zeitwerk)
64
+ #
65
+ # @yield Dashboard definition DSL
66
+ # @return [AdminSuite::UI::DashboardDefinition]
67
+ def root_dashboard(&block)
68
+ config.root_dashboard_definition ||= UI::DashboardDefinition.new
69
+ UI::DashboardDSL.new(config.root_dashboard_definition).instance_eval(&block) if block_given?
70
+ config.root_dashboard_definition
71
+ end
72
+
73
+ # @return [AdminSuite::UI::DashboardDefinition, nil]
74
+ def root_dashboard_definition
75
+ config.root_dashboard_definition
76
+ end
77
+
78
+ # Clears the root dashboard definition (useful for development reloads).
79
+ #
80
+ # @return [void]
81
+ def reset_root_dashboard!
82
+ config.root_dashboard_definition = nil
83
+ config.root_dashboard_loaded = false
84
+ rescue StandardError
85
+ # best-effort
86
+ end
56
87
  end
57
88
  end
@@ -20,6 +20,14 @@ AdminSuite.configure do |config|
20
20
  Rails.root.join("app/admin/resources/*.rb").to_s
21
21
  ]
22
22
 
23
+ # Action handler file globs (host app can override).
24
+ #
25
+ # Files typically define `Admin::Actions::<Resource><Action>Action` handlers.
26
+ config.action_globs = [
27
+ Rails.root.join("config/admin_suite/actions/*.rb").to_s,
28
+ Rails.root.join("app/admin/actions/*.rb").to_s
29
+ ]
30
+
23
31
  # Portal dashboard DSL globs (host app can override).
24
32
  # Files typically call `AdminSuite.portal :ops do ... end`
25
33
  config.portal_globs = [