admin_suite 0.2.0 → 0.2.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f761dd4e5538b7fb655ce9a29c936bfa57d1cf56e06d3aa73e064e4721ec69a9
4
- data.tar.gz: e4a584d9645f7f848ad48c6f94a47ce34d9a9820cbe1c6271c635b17f1176719
3
+ metadata.gz: 8a3f83815ccce6bcb0e0a38e07e0b90d6d969309b2e3302e1870135f02793762
4
+ data.tar.gz: 2564e620eaa8c66030d8f774b1d4246eaa4359955ce2a5670f62f069a88a58af
5
5
  SHA512:
6
- metadata.gz: 9e0c72f5c7494b4a5378cd0895781bbbb41c8ed372788bf9cbcbeaa2830866f87c0636fce56872ee6998dbb516552f8bd105a828864ee29d7f23cc2ec77949e5
7
- data.tar.gz: 4f2fc0a2d4d6edcb75ab99d7e888e8cd7abef38a9790e5ff45b74f8a8e6e9b6d7d657186776e2fc4132b59b051b21769ff966b2a33f050007505648116a74652
6
+ metadata.gz: '071608f05dc059b788b90350e9cfbd590d417bf124f3327dbf151202d32f2c71359f3c6ba442e13fba7138d8fc7b28d96e52687825bf64beb66c4058dd640eb9'
7
+ data.tar.gz: ac7a393a423342b228c9489492d361e34f98afaaf9e9527a1a71639ecc3d0ee7ce59ddfae3988d1d83ccac474a69764ce7b7d7afed0e346026642bd9c69bb3f2
@@ -35,12 +35,11 @@ module AdminSuite
35
35
  nil
36
36
  end
37
37
 
38
- # Loads resource definition files in development when needed.
38
+ # Loads resource definition files when needed (runs in all environments).
39
39
  #
40
40
  # @return [void]
41
41
  def ensure_resources_loaded!
42
42
  require "admin/base/resource" unless defined?(Admin::Base::Resource)
43
- return unless Rails.env.development?
44
43
  return if Admin::Base::Resource.registered_resources.any?
45
44
 
46
45
  Array(AdminSuite.config.resource_globs).flat_map { |g| Dir[g] }.uniq.each do |file|
@@ -32,16 +32,27 @@ 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
42
 
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.
43
+ Note: AdminSuite definition files (resources, actions, portals) often don't follow
44
+ Zeitwerk's path-to-constant naming conventions. To prevent eager-load `Zeitwerk::NameError`s
45
+ in production, the engine only configures Zeitwerk to ignore these directories and load them via globs instead:
46
+ - `app/admin_suite`
47
+ - `app/admin/portals` (when portal DSL usage is detected)
48
+
49
+ Other `app/admin/*` directories (such as `app/admin/resources`, `app/admin/actions`, and `app/admin/base`) are
50
+ not ignored by default and may be treated as normal Zeitwerk autoload paths if they are added to the loader
51
+ (for example, via `loader.push_dir("app/admin")` in the host app). Do not rely on these directories being
52
+ ignored for autoloading; instead, keep files there Zeitwerk-compatible.
53
+
54
+ We recommend placing non-Zeitwerk-compatible definition files under `config/admin_suite/*` or `app/admin_suite/*`
55
+ for clearer separation from standard Rails autoloading.
45
56
  - `portals`: default portal metadata for `:ops`, `:email`, `:ai`, `:assistant`
46
57
  - `custom_renderers`: `{}`
47
58
  - `icon_renderer`: `nil` (uses lucide-rails by default when available)
@@ -95,6 +106,20 @@ config.resource_globs = [
95
106
  ]
96
107
  ```
97
108
 
109
+ ### `action_globs`
110
+
111
+ Where AdminSuite should load action handler files from (files that define custom action handlers, typically subclasses of `Admin::Base::ActionHandler`).
112
+
113
+ - **Type**: `Array<String>`
114
+
115
+ Example:
116
+
117
+ ```ruby
118
+ config.action_globs = [
119
+ Rails.root.join("app/admin/actions/**/*.rb").to_s
120
+ ]
121
+ ```
122
+
98
123
  ### `portal_globs`
99
124
 
100
125
  Where AdminSuite should load portal definition files from (files typically call `AdminSuite.portal(:key) { ... }`).
@@ -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,6 +7,7 @@ module AdminSuite
7
7
  :current_actor,
8
8
  :authorize,
9
9
  :resource_globs,
10
+ :action_globs,
10
11
  :portal_globs,
11
12
  :portals,
12
13
  :custom_renderers,
@@ -25,6 +26,7 @@ module AdminSuite
25
26
  @current_actor = nil
26
27
  @authorize = nil
27
28
  @resource_globs = []
29
+ @action_globs = []
28
30
  @portal_globs = []
29
31
  @portals = {}
30
32
  @custom_renderers = {}
@@ -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,
@@ -2,7 +2,7 @@
2
2
 
3
3
  module AdminSuite
4
4
  module Version
5
- VERSION = "0.2.0"
5
+ VERSION = "0.2.1"
6
6
  end
7
7
 
8
8
  # Backward-compatible constant.
@@ -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 = [