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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +82 -0
- data/app/assets/vendor/easymde.min.css +7 -0
- data/app/assets/vendor/easymde.min.js +7 -0
- data/app/controllers/admin_suite/application_controller.rb +26 -47
- data/app/controllers/admin_suite/portals_controller.rb +2 -2
- data/app/controllers/admin_suite/resources_controller.rb +8 -1
- data/app/helpers/admin_suite/base_helper.rb +49 -385
- data/app/javascript/controllers/admin_suite/markdown_editor_controller.js +21 -2
- data/app/views/admin_suite/panels/_stat.html.erb +10 -0
- data/app/views/admin_suite/resources/index.html.erb +7 -77
- data/app/views/admin_suite/shared/_pagination.html.erb +74 -0
- data/app/views/admin_suite/shared/_sidebar.html.erb +1 -1
- data/app/views/layouts/admin_suite/application.html.erb +5 -3
- data/lib/admin/base/action_executor.rb +19 -51
- data/lib/admin/base/resource.rb +53 -14
- data/lib/admin_suite/configuration.rb +28 -3
- data/lib/admin_suite/definition_loader.rb +194 -0
- data/lib/admin_suite/deprecation.rb +48 -0
- data/lib/admin_suite/engine.rb +55 -76
- data/lib/admin_suite/host_autoload_policy.rb +132 -0
- data/lib/admin_suite/legacy_custom_renderer_procs.rb +29 -0
- data/lib/admin_suite/portal_definition.rb +11 -0
- data/lib/admin_suite/renderer.rb +133 -0
- data/lib/admin_suite/renderer_registry.rb +70 -0
- data/lib/admin_suite/renderers/code_renderer.rb +15 -0
- data/lib/admin_suite/renderers/json_renderer.rb +15 -0
- data/lib/admin_suite/renderers/key_value_renderer.rb +39 -0
- data/lib/admin_suite/renderers/legacy_gleania.rb +230 -0
- data/lib/admin_suite/renderers/table_from_renderer.rb +22 -0
- data/lib/admin_suite/section_definition.rb +42 -0
- data/lib/admin_suite/ui/field_renderer_registry.rb +31 -4
- data/lib/admin_suite/ui/form_field_renderer.rb +1 -7
- data/lib/admin_suite/ui/show_formatter_registry.rb +9 -0
- data/lib/admin_suite/ui/show_value_formatter.rb +7 -3
- data/lib/admin_suite/version.rb +1 -1
- data/lib/admin_suite.rb +37 -0
- data/lib/generators/admin_suite/install/templates/admin_suite.rb +0 -4
- data/test/controllers/resources_controller_test.rb +76 -1
- data/test/integration/layout_assets_test.rb +112 -0
- data/test/integration/navigation_sections_test.rb +41 -0
- data/test/integration/pagination_and_stats_test.rb +152 -0
- data/test/lib/action_executor_redirect_test.rb +41 -0
- data/test/lib/builtin_renderers_test.rb +202 -0
- data/test/lib/definition_loader_test.rb +264 -0
- data/test/lib/engine_defaults_test.rb +39 -0
- data/test/lib/form_field_renderer_test.rb +64 -0
- data/test/lib/legacy_renderer_deprecation_test.rb +35 -0
- data/test/lib/renderer_test.rb +221 -0
- data/test/lib/resource_exportable_deprecation_test.rb +39 -0
- data/test/lib/show_value_formatter_test.rb +88 -0
- data/test/lib/zeitwerk_integration_test.rb +28 -64
- data/test/test_helper.rb +77 -0
- 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
|
data/lib/admin_suite/engine.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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,
|