admin_suite 0.3.1 → 0.4.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.
Files changed (54) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +82 -0
  3. data/app/assets/vendor/easymde.min.css +7 -0
  4. data/app/assets/vendor/easymde.min.js +7 -0
  5. data/app/controllers/admin_suite/application_controller.rb +26 -47
  6. data/app/controllers/admin_suite/portals_controller.rb +2 -2
  7. data/app/controllers/admin_suite/resources_controller.rb +8 -1
  8. data/app/helpers/admin_suite/base_helper.rb +49 -385
  9. data/app/javascript/controllers/admin_suite/markdown_editor_controller.js +21 -2
  10. data/app/views/admin_suite/panels/_stat.html.erb +10 -0
  11. data/app/views/admin_suite/resources/index.html.erb +7 -77
  12. data/app/views/admin_suite/shared/_pagination.html.erb +74 -0
  13. data/app/views/admin_suite/shared/_sidebar.html.erb +1 -1
  14. data/app/views/layouts/admin_suite/application.html.erb +5 -3
  15. data/lib/admin/base/action_executor.rb +19 -51
  16. data/lib/admin/base/resource.rb +53 -14
  17. data/lib/admin_suite/configuration.rb +28 -3
  18. data/lib/admin_suite/definition_loader.rb +194 -0
  19. data/lib/admin_suite/deprecation.rb +48 -0
  20. data/lib/admin_suite/engine.rb +55 -76
  21. data/lib/admin_suite/host_autoload_policy.rb +132 -0
  22. data/lib/admin_suite/legacy_custom_renderer_procs.rb +29 -0
  23. data/lib/admin_suite/portal_definition.rb +11 -0
  24. data/lib/admin_suite/renderer.rb +133 -0
  25. data/lib/admin_suite/renderer_registry.rb +70 -0
  26. data/lib/admin_suite/renderers/code_renderer.rb +15 -0
  27. data/lib/admin_suite/renderers/json_renderer.rb +15 -0
  28. data/lib/admin_suite/renderers/key_value_renderer.rb +39 -0
  29. data/lib/admin_suite/renderers/legacy_gleania.rb +230 -0
  30. data/lib/admin_suite/renderers/table_from_renderer.rb +22 -0
  31. data/lib/admin_suite/section_definition.rb +42 -0
  32. data/lib/admin_suite/ui/field_renderer_registry.rb +31 -4
  33. data/lib/admin_suite/ui/form_field_renderer.rb +1 -7
  34. data/lib/admin_suite/ui/show_formatter_registry.rb +9 -0
  35. data/lib/admin_suite/ui/show_value_formatter.rb +7 -3
  36. data/lib/admin_suite/version.rb +1 -1
  37. data/lib/admin_suite.rb +37 -0
  38. data/lib/generators/admin_suite/install/templates/admin_suite.rb +0 -4
  39. data/test/controllers/resources_controller_test.rb +76 -1
  40. data/test/integration/layout_assets_test.rb +112 -0
  41. data/test/integration/navigation_sections_test.rb +41 -0
  42. data/test/integration/pagination_and_stats_test.rb +152 -0
  43. data/test/lib/action_executor_redirect_test.rb +41 -0
  44. data/test/lib/builtin_renderers_test.rb +202 -0
  45. data/test/lib/definition_loader_test.rb +264 -0
  46. data/test/lib/engine_defaults_test.rb +39 -0
  47. data/test/lib/form_field_renderer_test.rb +64 -0
  48. data/test/lib/legacy_renderer_deprecation_test.rb +35 -0
  49. data/test/lib/renderer_test.rb +221 -0
  50. data/test/lib/resource_exportable_deprecation_test.rb +39 -0
  51. data/test/lib/show_value_formatter_test.rb +88 -0
  52. data/test/lib/zeitwerk_integration_test.rb +28 -64
  53. data/test/test_helper.rb +77 -0
  54. metadata +28 -1
@@ -0,0 +1,194 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AdminSuite
4
+ # Single glob-and-load implementation for the four families of definition
5
+ # files a host app may drop under its configured glob paths: resources,
6
+ # portals, root dashboards, and action handlers.
7
+ #
8
+ # Replaces five near-duplicate loaders (three in ApplicationController, one
9
+ # in BaseHelper, one in ActionExecutor) that each re-implemented the same
10
+ # glob -> require/load shape with three different reload policies and, in
11
+ # two cases, `rescue NameError; retry` used as control flow.
12
+ #
13
+ # Reload policy (uniform across all four kinds -- `load!` itself no longer
14
+ # branches on environment except for *how* a matching file gets loaded):
15
+ # - `loaded?` gates every call, in every environment: once a kind is
16
+ # loaded, `load!(kind)` is a no-op until something explicitly clears it.
17
+ # - In development, matching files are loaded with `load` instead of
18
+ # `require`; everywhere else, `require` (loaded once per process,
19
+ # forever). This only matters for kinds that actually get reloaded a
20
+ # second time -- `:resources` is excluded from that (see below), so for
21
+ # resources `load` vs `require` is a distinction without a difference:
22
+ # the file is read exactly once, same as pre-0.4.0.
23
+ # - Live-reload in development comes from *outside* `load!`: the engine's
24
+ # "admin_suite.definition_reload" initializer hooks `reset_for_new_request!`
25
+ # into `app.reloader.to_prepare`, which Rails runs once at boot and again
26
+ # on any request where it detects a change in a watched path. That clears
27
+ # the loaded flag (and, for kinds where it's safe, the registry) so the
28
+ # *next* `load!` call re-globs and reloads -- at most once per such
29
+ # request, not once per call. See `reset_for_new_request!` for why
30
+ # `:resources` is deliberately excluded from that reset (and gets no
31
+ # live-reload at all as a result).
32
+ #
33
+ # Error policy (generalized from ActionExecutor's pre-existing policy):
34
+ # log the failing file and error, then re-raise in development/test so
35
+ # broken definition files are immediately discoverable; swallow (after
36
+ # logging) elsewhere so one bad file doesn't take down a production boot
37
+ # or request.
38
+ class DefinitionLoader
39
+ # Maps each kind to:
40
+ # - glob_key: the AdminSuite::Configuration attribute holding glob
41
+ # patterns for this kind.
42
+ # - reset: clears this kind's registry/definition state. Used
43
+ # both by the test-facing `reset!` and, for
44
+ # `dev_resettable` kinds, by `reset_for_new_request!`.
45
+ # - loaded?: true if this kind's definitions are already loaded.
46
+ # Gates every `load!` call, in every environment.
47
+ # - mark_loaded!: records that a load attempt has completed. A no-op
48
+ # for kinds whose "loaded" state is self-evident from
49
+ # their registry (resources, portals): once something
50
+ # is registered, `loaded?` is already true. Kinds
51
+ # with no such registry (dashboards, actions) need an
52
+ # explicit flag so an all-empty-glob load doesn't
53
+ # re-scan the filesystem on every call.
54
+ # - dev_resettable: whether `reset_for_new_request!` (the development
55
+ # live-reload hook) is allowed to reset this kind
56
+ # between requests. False only for :resources --
57
+ # see the comment on that entry.
58
+ KINDS = {
59
+ resources: {
60
+ glob_key: :resource_globs,
61
+ reset: -> { Admin::Base::Resource.reset_registry! },
62
+ loaded?: -> { Admin::Base::Resource.registered_resources.any? },
63
+ mark_loaded!: -> {},
64
+ # Resource registration happens *only* via `Class#inherited`
65
+ # (lib/admin/base/resource.rb), which fires on class *creation* and
66
+ # never on reopening an already-defined class. Resetting the
67
+ # registry and then `load`ing the same file again reopens the
68
+ # already-defined class -- `inherited` does not refire, so nothing
69
+ # re-populates the registry, and it stays empty for the rest of the
70
+ # process. This bit twice within a single request in production use
71
+ # (`navigation_items` is re-entered by `portal_color`/`portal_icon`
72
+ # while building the sidebar), wiping the nav on the very first
73
+ # request. So :resources is never reset -- not for a dev-mode
74
+ # reload, not ever.
75
+ #
76
+ # Consequence (this is a real cost, not a wash): resources are
77
+ # loaded once per process. Once `loaded?` is true, `load!(:resources)`
78
+ # never runs again, so the file is never re-read -- `load` (vs
79
+ # `require`) buys nothing here, unlike the other three kinds, because
80
+ # nothing ever calls it a second time. In development, both edits to
81
+ # an existing resource file and newly added resource files require a
82
+ # restart, unchanged from pre-0.4.0 behavior (which had no dev reload
83
+ # path for resources at all).
84
+ dev_resettable: false
85
+ },
86
+ portals: {
87
+ glob_key: :portal_globs,
88
+ reset: -> { AdminSuite::PortalRegistry.reset! },
89
+ loaded?: -> { AdminSuite::PortalRegistry.all.any? },
90
+ mark_loaded!: -> {},
91
+ dev_resettable: true
92
+ },
93
+ dashboards: {
94
+ glob_key: :dashboard_globs,
95
+ reset: -> { AdminSuite.reset_root_dashboard! },
96
+ loaded?: -> { AdminSuite.config.root_dashboard_loaded },
97
+ mark_loaded!: -> { AdminSuite.config.root_dashboard_loaded = true },
98
+ dev_resettable: true
99
+ },
100
+ actions: {
101
+ glob_key: :action_globs,
102
+ reset: -> { Admin::Base::ActionExecutor.handlers_loaded = false },
103
+ loaded?: -> { Admin::Base::ActionExecutor.handlers_loaded },
104
+ mark_loaded!: -> { Admin::Base::ActionExecutor.handlers_loaded = true },
105
+ dev_resettable: true
106
+ }
107
+ }.freeze
108
+
109
+ class << self
110
+ # Loads the definition files configured for `kind`, per the reload
111
+ # policy above. A no-op once `kind` is loaded, until something resets
112
+ # it (a test's explicit `reset!`, or -- in development -- the next
113
+ # `reset_for_new_request!` triggered by Rails' reloader).
114
+ #
115
+ # @param kind [Symbol] one of :resources, :portals, :dashboards, :actions
116
+ # @raise [ArgumentError] for an unrecognized kind
117
+ # @return [void]
118
+ def load!(kind)
119
+ entry = kind_entry(kind)
120
+ return if entry[:loaded?].call
121
+
122
+ files = glob_files(entry)
123
+ if files.empty?
124
+ entry[:mark_loaded!].call
125
+ return
126
+ end
127
+
128
+ load_files(files, mode: Rails.env.development? ? :load : :require)
129
+ entry[:mark_loaded!].call
130
+ end
131
+
132
+ # Resets `kind`'s registry/definition state unconditionally. Used by
133
+ # tests that need a clean slate regardless of `dev_resettable`.
134
+ #
135
+ # @param kind [Symbol]
136
+ # @return [void]
137
+ def reset!(kind)
138
+ kind_entry(kind)[:reset].call
139
+ end
140
+
141
+ # Clears every `dev_resettable` kind's loaded state, so the next
142
+ # `load!` call for each re-globs and reloads. Wired into
143
+ # `app.reloader.to_prepare` by the engine, in development only (see
144
+ # "admin_suite.definition_reload" in engine.rb) -- this is what turns
145
+ # "load once, cache forever" into "reload at most once per request"
146
+ # instead of `load!` resetting on every single call.
147
+ #
148
+ # :resources is deliberately skipped: see its KINDS entry.
149
+ #
150
+ # @return [void]
151
+ def reset_for_new_request!
152
+ KINDS.each_value { |entry| entry[:reset].call if entry[:dev_resettable] }
153
+ end
154
+
155
+ private
156
+
157
+ def kind_entry(kind)
158
+ KINDS.fetch(kind) do
159
+ raise ArgumentError, "unknown AdminSuite::DefinitionLoader kind: #{kind.inspect}"
160
+ end
161
+ end
162
+
163
+ def glob_files(entry)
164
+ Array(AdminSuite.config.public_send(entry[:glob_key])).flat_map { |glob| Dir[glob] }.uniq
165
+ end
166
+
167
+ def load_files(files, mode:)
168
+ files.each do |file|
169
+ begin
170
+ mode == :load ? load(file) : require(file)
171
+ rescue StandardError, ScriptError => e
172
+ log_error(file, e)
173
+ raise if Rails.env.development? || Rails.env.test?
174
+ end
175
+ end
176
+ end
177
+
178
+ def log_error(file, error)
179
+ message = "[AdminSuite] Failed to load definition file #{file}: #{error.class}: #{error.message}"
180
+
181
+ if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
182
+ Rails.logger.error(message)
183
+
184
+ backtrace = Array(error.backtrace).take(20).join("\n")
185
+ Rails.logger.error(backtrace) unless backtrace.empty?
186
+ else
187
+ warn(message)
188
+ end
189
+ rescue StandardError
190
+ nil
191
+ end
192
+ end
193
+ end
194
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AdminSuite
4
+ # Per-process, once-per-key deprecation warning support.
5
+ #
6
+ # Originally lived only on `AdminSuite::Renderers::LegacyGleania` (the four
7
+ # legacy Gleania renderers); pulled out here so `Admin::Base::Resource`
8
+ # (the `exportable` no-op) and the legacy `config.custom_renderers` proc
9
+ # path can share the same once-per-key behavior instead of re-implementing
10
+ # it, without becoming a general-purpose deprecation framework.
11
+ #
12
+ # `extend` this module to get an independent `@warned_keys` store scoped to
13
+ # the extending object (a module, or -- via singleton-method inheritance --
14
+ # each subclass of a class that extends it).
15
+ module Deprecation
16
+ # Swappable sink for the deprecation message -- a method rather than an
17
+ # attribute so `Minitest::Mock#stub(:warn_once_sink, replacement)` can
18
+ # intercept it directly: `stub` invokes the replacement with whatever
19
+ # arguments the stubbed call site passes (here, the message), rather
20
+ # than substituting it as a return value. Defaults to logging via
21
+ # `Rails.logger`.
22
+ #
23
+ # @param msg [String]
24
+ # @return [void]
25
+ def warn_once_sink(msg)
26
+ Rails.logger&.warn(msg)
27
+ end
28
+
29
+ # @return [void]
30
+ def reset_deprecation_notices!
31
+ @warned_keys = {}
32
+ end
33
+
34
+ # Fires the deprecation sink at most once per `key` per process (per
35
+ # extending object).
36
+ #
37
+ # @param key [Object] anything hashable identifying the deprecated thing
38
+ # @param message [String] fully-formatted deprecation message
39
+ # @return [void]
40
+ def warn_once(key, message)
41
+ @warned_keys ||= {}
42
+ return if @warned_keys[key]
43
+
44
+ @warned_keys[key] = true
45
+ warn_once_sink(message)
46
+ end
47
+ end
48
+ end
@@ -14,65 +14,7 @@ module AdminSuite
14
14
  end
15
15
 
16
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
- #
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)
40
- end
41
-
42
- Rails.autoloaders.each do |loader|
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
75
- end
17
+ AdminSuite::HostAutoloadPolicy.apply!(app)
76
18
  end
77
19
 
78
20
  initializer "admin_suite.admin_dsl" do
@@ -83,17 +25,6 @@ module AdminSuite
83
25
  require "admin/base/action_handler"
84
26
  end
85
27
 
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
-
97
28
  initializer "admin_suite.watchable_dirs" do |app|
98
29
  next unless Rails.env.development?
99
30
 
@@ -103,6 +34,44 @@ module AdminSuite
103
34
  app.config.watchable_dirs[root.join("config").to_s] = %w[rb]
104
35
  end
105
36
 
37
+ initializer "admin_suite.host_watchable_dirs" do |app|
38
+ next unless Rails.env.development?
39
+
40
+ # Rails only re-runs `to_prepare` callbacks (see
41
+ # "admin_suite.definition_reload" below) when it detects a change in a
42
+ # watched path -- by default, `config.autoload_paths` +
43
+ # `eager_load_paths` + `watchable_files` + `watchable_dirs`.
44
+ # `app/admin*` directories are covered automatically (Rails treats
45
+ # every directory under `app/` as an autoload path). But
46
+ # `config/admin_suite/**` -- the *recommended*, deliberately
47
+ # non-autoload location for resource/portal/dashboard/action
48
+ # definitions (see "admin_suite.configuration" below) -- isn't in any
49
+ # of those by default, so editing only a file there would never be
50
+ # noticed. Watch it explicitly.
51
+ host_admin_suite_config_dir = Rails.root.join("config/admin_suite")
52
+ next unless host_admin_suite_config_dir.exist?
53
+
54
+ app.config.watchable_dirs[host_admin_suite_config_dir.to_s] = %w[rb]
55
+ end
56
+
57
+ initializer "admin_suite.definition_reload" do |app|
58
+ next unless Rails.env.development?
59
+
60
+ # Drives DefinitionLoader's development live-reload. Rails runs
61
+ # `to_prepare` callbacks once at boot and again on any request where
62
+ # it detects a change in a watched path (see
63
+ # "admin_suite.host_watchable_dirs" above) -- NOT unconditionally on
64
+ # every request (that's gated by `config.reload_classes_only_on_change`,
65
+ # true by default; see Rails::Application::Finisher#set_clear_dependencies_hook).
66
+ # Clearing the loaded flags here, rather than inside `load!` itself,
67
+ # is what bounds a reload to at most once per such request instead of
68
+ # once per `load!` call (application_controller.rb's `navigation_items`
69
+ # alone triggers several per request).
70
+ app.reloader.to_prepare do
71
+ AdminSuite::DefinitionLoader.reset_for_new_request!
72
+ end
73
+ end
74
+
106
75
  initializer "admin_suite.assets", before: "propshaft" do |app|
107
76
  # Make engine JS/CSS available to the host asset pipeline (Propshaft/Sprockets).
108
77
  app.config.assets.paths << root.join("app/javascript")
@@ -150,15 +119,25 @@ module AdminSuite
150
119
  ]
151
120
  end
152
121
 
153
- config.portals = {
154
- ops: { label: "Ops Portal", icon: "settings", color: :amber, order: 10 },
155
- email: { label: "Email Portal", icon: "inbox", color: :emerald, order: 20 },
156
- ai: { label: "AI Portal", icon: "cpu", color: :cyan, order: 30 },
157
- assistant: { label: "Assistant Portal", icon: "message-circle", color: :violet, order: 40 }
158
- } if config.portals.blank?
122
+ self.class.apply_default_portals!(config)
159
123
  end
160
124
  end
161
125
 
126
+ # Applies the engine's built-in default portals, unless the host has
127
+ # explicitly assigned `config.portals` itself (even to `{}`). Extracted
128
+ # from the "admin_suite.configuration" initializer so it is directly
129
+ # testable without booting a full Rails app.
130
+ def self.apply_default_portals!(config)
131
+ return if config.portals_configured? || config.portals.present?
132
+
133
+ config.send(:default_portals!, {
134
+ ops: { label: "Ops Portal", icon: "settings", color: :amber, order: 10 },
135
+ email: { label: "Email Portal", icon: "inbox", color: :emerald, order: 20 },
136
+ ai: { label: "AI Portal", icon: "cpu", color: :cyan, order: 30 },
137
+ assistant: { label: "Assistant Portal", icon: "message-circle", color: :violet, order: 40 }
138
+ })
139
+ end
140
+
162
141
  initializer "admin_suite.tailwind_build" do
163
142
  next unless Rails.env.development?
164
143
 
@@ -0,0 +1,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AdminSuite
4
+ # Zeitwerk integration for host apps, extracted from the engine's
5
+ # "admin_suite.host_dsl_ignore" initializer so it is directly testable
6
+ # against the real Zeitwerk-loader interface instead of a copy of it.
7
+ #
8
+ # Host apps may store AdminSuite DSL files under `app/admin_suite/**` and
9
+ # `app/admin/portals/**`. These are side-effect DSL files (they do not
10
+ # define constants), so Zeitwerk must ignore them to avoid eager-load
11
+ # `Zeitwerk::NameError`s in production. Host apps may also use `Admin::*`
12
+ # constants inside `app/admin/**`, which needs a namespace mapping since
13
+ # Rails' default autoload root (`app/admin`) otherwise expects top-level
14
+ # constants.
15
+ class HostAutoloadPolicy
16
+ class << self
17
+ # @param app [Rails::Application] the host application
18
+ # @param loaders [#each, #main] defaults to `Rails.autoloaders`. Tests
19
+ # may inject a plain Array of fake loaders; `main` is used for
20
+ # `push_dir` (falling back to the first loader when the given
21
+ # collection doesn't respond to `#main`) and every loader in the
22
+ # collection is given a chance to `#ignore` the DSL directories.
23
+ # @return [void]
24
+ def apply!(app, loaders: Rails.autoloaders)
25
+ root = app.root
26
+
27
+ admin_suite_app_dir = root.join("app/admin_suite")
28
+ admin_dir = root.join("app/admin")
29
+ admin_portals_dir = root.join("app/admin/portals")
30
+
31
+ # If the host uses `Admin::*` constants inside `app/admin/**`, Rails' default
32
+ # autoload root (`app/admin`) would expect top-level constants like
33
+ # `Resources::UserResource`. We fix that by mapping `app/admin` to `Admin`.
34
+ # This avoids requiring host apps to add their own Zeitwerk initializer.
35
+ admin_dir_mapped = admin_dir.exist? && host_admin_namespace_files?(admin_dir)
36
+
37
+ if admin_dir_mapped
38
+ admin_dir_s = admin_dir.to_s
39
+ app.config.autoload_paths.delete(admin_dir_s)
40
+ app.config.eager_load_paths.delete(admin_dir_s)
41
+
42
+ # Ensure `Admin` exists so Zeitwerk can use it as a namespace.
43
+ ensure_module!(Object, :Admin)
44
+
45
+ main_loader(loaders).push_dir(admin_dir, namespace: ::Admin)
46
+ # `app/admin/renderers/foo_renderer.rb` now resolves to
47
+ # `Admin::Renderers::FooRenderer` under this same push_dir, with no
48
+ # extra work: the whole `app/admin` tree (including `renderers/`) is
49
+ # namespaced under `::Admin` by Zeitwerk's directory conventions.
50
+ end
51
+
52
+ # A host with ONLY DSL files under `app/admin` (no `Admin::*` constants
53
+ # anywhere) never takes the branch above, so `app/admin` remains a
54
+ # default Rails autoload root mapping to top-level constants. Without
55
+ # this, `app/admin/renderers/foo_renderer.rb` would need to define a
56
+ # top-level `Renderers::FooRenderer` instead of the documented
57
+ # `Admin::Renderers::FooRenderer`, and `host_renderer_class` in
58
+ # base_helper.rb would never find it. Map `app/admin/renderers`
59
+ # explicitly in that case.
60
+ renderers_dir = root.join("app/admin/renderers")
61
+ if renderers_dir.exist? && !admin_dir_mapped
62
+ ensure_module!(Object, :Admin)
63
+ ensure_module!(::Admin, :Renderers)
64
+ main_loader(loaders).push_dir(renderers_dir, namespace: ::Admin::Renderers)
65
+ end
66
+
67
+ each_loader(loaders) do |loader|
68
+ loader.ignore(admin_suite_app_dir) if admin_suite_app_dir.exist?
69
+
70
+ next unless admin_portals_dir.exist?
71
+
72
+ loader.ignore(admin_portals_dir) if contains_admin_suite_portal_dsl?(admin_portals_dir)
73
+ end
74
+ end
75
+
76
+ # True if any file under app/admin (excluding app/admin/portals)
77
+ # appears to define `Admin::*` constants.
78
+ #
79
+ # @param admin_dir [Pathname]
80
+ # @return [Boolean]
81
+ def host_admin_namespace_files?(admin_dir)
82
+ Dir[admin_dir.join("**/*.rb").to_s].any? do |file|
83
+ next false if file.include?("/portals/")
84
+
85
+ content = File.binread(file)
86
+ content = content.encode("UTF-8", invalid: :replace, undef: :replace, replace: "")
87
+
88
+ content.match?(/\b(module|class)\s+Admin\b/) ||
89
+ content.match?(/\b(module|class)\s+Admin::/)
90
+ rescue StandardError
91
+ false
92
+ end
93
+ end
94
+
95
+ # True if any file under `admin_portals_dir` uses the `AdminSuite.portal`
96
+ # DSL (as opposed to defining real constants).
97
+ #
98
+ # @param admin_portals_dir [Pathname]
99
+ # @return [Boolean]
100
+ def contains_admin_suite_portal_dsl?(admin_portals_dir)
101
+ portal_files = Dir[admin_portals_dir.join("**/*.rb").to_s]
102
+ portal_files.any? do |file|
103
+ content = File.binread(file)
104
+ content = content.encode("UTF-8", invalid: :replace, undef: :replace, replace: "")
105
+ portal_dsl_pattern = /(::)?AdminSuite\s*\.\s*portal\b/
106
+ portal_dsl_pattern.match?(content)
107
+ rescue StandardError
108
+ false
109
+ end
110
+ end
111
+
112
+ private
113
+
114
+ def main_loader(loaders)
115
+ loaders.respond_to?(:main) ? loaders.main : Array(loaders).first
116
+ end
117
+
118
+ # `module Foo; end` can't appear inside a method body (it's a syntax
119
+ # error there, unlike inside a block), so namespace creation goes
120
+ # through `const_set` instead.
121
+ def ensure_module!(parent, name)
122
+ return if parent.const_defined?(name, false)
123
+
124
+ parent.const_set(name, Module.new)
125
+ end
126
+
127
+ def each_loader(loaders, &block)
128
+ loaders.each(&block)
129
+ end
130
+ end
131
+ end
132
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AdminSuite
4
+ # Deprecation warning for `config.custom_renderers[:key] = ->(record, view) {}`
5
+ # procs (deprecated on paper since 0.4.0 — see CHANGELOG). They still take
6
+ # top precedence in `render_custom_section`, unchanged: Task 3 has tests
7
+ # pinning that. Unlike the four legacy Gleania renderers, though, these
8
+ # warned nothing at runtime, so a host mid-migration (trust_growth has 23
9
+ # of them) has no signal to notice by. Warns once per key per process.
10
+ module LegacyCustomRendererProcs
11
+ extend AdminSuite::Deprecation
12
+
13
+ DEPRECATION_MESSAGE_FORMAT =
14
+ "AdminSuite: config.custom_renderers[:%<key>s] is a deprecated proc " \
15
+ "and will be removed in 0.5.0. Migrate to an AdminSuite::Renderer subclass."
16
+
17
+ class << self
18
+ # Fires the deprecation sink at most once per `key` per process.
19
+ # `warn_once_sink` and `reset_deprecation_notices!` come from
20
+ # `AdminSuite::Deprecation`, extended above.
21
+ #
22
+ # @param key [Symbol]
23
+ # @return [void]
24
+ def warn_once(key)
25
+ super(key, format(DEPRECATION_MESSAGE_FORMAT, key: key))
26
+ end
27
+ end
28
+ end
29
+ end
@@ -14,6 +14,7 @@ module AdminSuite
14
14
  @order = nil
15
15
  @description = nil
16
16
  @dashboard = nil
17
+ @sections = {}
17
18
  end
18
19
 
19
20
  def label(value = nil)
@@ -51,6 +52,16 @@ module AdminSuite
51
52
  @dashboard
52
53
  end
53
54
 
55
+ def section(key, &block)
56
+ definition = (@sections[key.to_sym] ||= SectionDefinition.new(key))
57
+ definition.instance_eval(&block) if block_given?
58
+ definition
59
+ end
60
+
61
+ def sections
62
+ @sections
63
+ end
64
+
54
65
  def to_nav_meta
55
66
  {
56
67
  label: @label,