admin_suite 0.3.1 → 0.5.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 +210 -1
- data/app/assets/vendor/chart.umd.min.js +14 -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 +138 -6
- data/app/helpers/admin_suite/base_helper.rb +232 -391
- data/app/javascript/admin_suite_application.js +3 -0
- data/app/javascript/controllers/admin_suite/chart_controller.js +173 -0
- data/app/javascript/controllers/admin_suite/markdown_editor_controller.js +21 -2
- data/app/views/admin_suite/panels/_chart.html.erb +158 -18
- data/app/views/admin_suite/panels/_stat.html.erb +10 -0
- data/app/views/admin_suite/resources/index.html.erb +31 -82
- data/app/views/admin_suite/shared/_pagination.html.erb +74 -0
- data/app/views/admin_suite/shared/_sidebar.html.erb +15 -9
- data/app/views/layouts/admin_suite/application.html.erb +11 -3
- data/config/routes.rb +3 -0
- data/lib/admin/base/action_executor.rb +19 -51
- data/lib/admin/base/filter_builder.rb +48 -5
- data/lib/admin/base/resource.rb +92 -17
- data/lib/admin_suite/configuration.rb +32 -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 +81 -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 +232 -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/dashboard_definition.rb +6 -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 +48 -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/association_linking_test.rb +292 -0
- data/test/integration/chart_panel_test.rb +491 -0
- data/test/integration/dashboard_test.rb +9 -2
- data/test/integration/index_table_test.rb +289 -0
- data/test/integration/layout_assets_test.rb +112 -0
- data/test/integration/navigation_sections_test.rb +62 -0
- data/test/integration/pagination_and_stats_test.rb +152 -0
- data/test/integration/searchable_select_search_test.rb +368 -0
- data/test/integration/show_hide_blank_test.rb +195 -0
- data/test/integration/toggle_test.rb +106 -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 +276 -0
- data/test/lib/engine_defaults_test.rb +39 -0
- data/test/lib/form_field_renderer_test.rb +136 -0
- data/test/lib/format_table_cell_test.rb +49 -0
- data/test/lib/index_includes_test.rb +223 -0
- data/test/lib/legacy_renderer_deprecation_test.rb +42 -0
- data/test/lib/renderer_test.rb +237 -0
- data/test/lib/resource_exportable_deprecation_test.rb +47 -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 +121 -5
- metadata +62 -5
|
@@ -81,37 +81,14 @@ module AdminSuite
|
|
|
81
81
|
#
|
|
82
82
|
# @return [void]
|
|
83
83
|
def ensure_resources_loaded!
|
|
84
|
-
|
|
85
|
-
return if Admin::Base::Resource.registered_resources.any?
|
|
86
|
-
|
|
87
|
-
Array(AdminSuite.config.resource_globs).flat_map { |g| Dir[g] }.uniq.each do |file|
|
|
88
|
-
require file
|
|
89
|
-
end
|
|
90
|
-
rescue NameError
|
|
91
|
-
# Ensure base DSL is loaded first.
|
|
92
|
-
require "admin/base/resource"
|
|
93
|
-
retry
|
|
84
|
+
AdminSuite::DefinitionLoader.load!(:resources)
|
|
94
85
|
end
|
|
95
86
|
|
|
96
87
|
# Loads portal definition files in development (safe to call per-request).
|
|
97
88
|
#
|
|
98
89
|
# @return [void]
|
|
99
90
|
def ensure_portals_loaded!
|
|
100
|
-
|
|
101
|
-
return if globs.empty?
|
|
102
|
-
|
|
103
|
-
if Rails.env.development?
|
|
104
|
-
# Re-evaluate definitions on each request in development.
|
|
105
|
-
AdminSuite::PortalRegistry.reset!
|
|
106
|
-
globs.each { |file| load file }
|
|
107
|
-
else
|
|
108
|
-
# In non-dev, load once (typically at boot / first request).
|
|
109
|
-
return if AdminSuite::PortalRegistry.all.any?
|
|
110
|
-
globs.each { |file| require file }
|
|
111
|
-
end
|
|
112
|
-
rescue NameError
|
|
113
|
-
require "admin_suite"
|
|
114
|
-
retry
|
|
91
|
+
AdminSuite::DefinitionLoader.load!(:portals)
|
|
115
92
|
end
|
|
116
93
|
|
|
117
94
|
# Loads the root dashboard definition files (safe to call per-request).
|
|
@@ -122,33 +99,25 @@ module AdminSuite
|
|
|
122
99
|
#
|
|
123
100
|
# @return [void]
|
|
124
101
|
def ensure_root_dashboard_loaded!
|
|
125
|
-
|
|
126
|
-
globs = Array(AdminSuite.config.dashboard_globs).flat_map { |g| Dir[g] }.uniq
|
|
127
|
-
# Re-evaluate dashboard layout on each request in development.
|
|
128
|
-
# Always reset, even when no files match, so removed dashboards are cleared.
|
|
129
|
-
AdminSuite.reset_root_dashboard!
|
|
130
|
-
globs.each { |file| load file }
|
|
131
|
-
else
|
|
132
|
-
# In non-dev, load once.
|
|
133
|
-
return if AdminSuite.config.root_dashboard_loaded
|
|
134
|
-
globs = Array(AdminSuite.config.dashboard_globs).flat_map { |g| Dir[g] }.uniq
|
|
135
|
-
if globs.empty?
|
|
136
|
-
# Avoid hitting the filesystem on every request when no dashboard files exist.
|
|
137
|
-
AdminSuite.config.root_dashboard_loaded = true
|
|
138
|
-
return
|
|
139
|
-
end
|
|
140
|
-
globs.each { |file| require file }
|
|
141
|
-
AdminSuite.config.root_dashboard_loaded = true
|
|
142
|
-
end
|
|
143
|
-
rescue NameError
|
|
144
|
-
require "admin_suite"
|
|
145
|
-
retry
|
|
102
|
+
AdminSuite::DefinitionLoader.load!(:dashboards)
|
|
146
103
|
end
|
|
147
104
|
|
|
148
105
|
# Builds the navigation structure from registered resources.
|
|
149
106
|
#
|
|
107
|
+
# Memoized per request/controller-instance: views re-enter this method
|
|
108
|
+
# repeatedly while rendering a single page (e.g. `portal_color` and
|
|
109
|
+
# `portal_icon` in base_helper.rb each call it, and the sidebar partial
|
|
110
|
+
# calls those once per portal), and it's rebuilt from scratch each time
|
|
111
|
+
# it isn't memoized -- on top of that, `ensure_resources_loaded!` /
|
|
112
|
+
# `ensure_portals_loaded!` would otherwise glob the filesystem on every
|
|
113
|
+
# one of those re-entries in development.
|
|
114
|
+
#
|
|
150
115
|
# @return [Hash]
|
|
151
116
|
def navigation_items
|
|
117
|
+
@navigation_items ||= build_navigation_items
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def build_navigation_items
|
|
152
121
|
ensure_resources_loaded!
|
|
153
122
|
ensure_portals_loaded!
|
|
154
123
|
|
|
@@ -163,6 +132,13 @@ module AdminSuite
|
|
|
163
132
|
navigation[key.to_sym] ||= { label: key.to_s.humanize, order: 100, sections: {} }
|
|
164
133
|
navigation[key.to_sym].merge!(definition.to_nav_meta)
|
|
165
134
|
navigation[key.to_sym][:sections] ||= {}
|
|
135
|
+
|
|
136
|
+
# Declared sections appear even before any resource is assigned to
|
|
137
|
+
# them, so their label/order take effect immediately.
|
|
138
|
+
definition.sections.each do |section_key, section_definition|
|
|
139
|
+
navigation[key.to_sym][:sections][section_key] ||=
|
|
140
|
+
{ label: section_key.to_s.humanize, order: 100, items: [] }.merge(section_definition.to_nav_meta)
|
|
141
|
+
end
|
|
166
142
|
end
|
|
167
143
|
|
|
168
144
|
Admin::Base::Resource.registered_resources.each do |resource|
|
|
@@ -172,7 +148,10 @@ module AdminSuite
|
|
|
172
148
|
section = resource.section_name.to_sym
|
|
173
149
|
|
|
174
150
|
navigation[portal] ||= { label: portal.to_s.humanize, order: 100, sections: {} }
|
|
175
|
-
navigation[portal][:sections][section] ||=
|
|
151
|
+
navigation[portal][:sections][section] ||= begin
|
|
152
|
+
declared = AdminSuite::PortalRegistry.all[portal]&.sections&.[](section)
|
|
153
|
+
{ label: section.to_s.humanize, order: 100, items: [] }.merge(declared&.to_nav_meta || {})
|
|
154
|
+
end
|
|
176
155
|
|
|
177
156
|
label = resource.nav_label.presence || resource.human_name_plural
|
|
178
157
|
navigation[portal][:sections][section][:items] << {
|
|
@@ -11,8 +11,8 @@ module AdminSuite
|
|
|
11
11
|
raise ActionController::RoutingError, "Portal not found" if @portal.blank?
|
|
12
12
|
|
|
13
13
|
@sections =
|
|
14
|
-
(@portal[:sections] || {}).sort_by { |(_k, s)| s[:label].to_s }.map do |section_key, section|
|
|
15
|
-
items = Array(section[:items]).sort_by { |it| it[:label].to_s }
|
|
14
|
+
(@portal[:sections] || {}).sort_by { |(_k, s)| [ (s[:order] || 100).to_i, s[:label].to_s ] }.map do |section_key, section|
|
|
15
|
+
items = Array(section[:items]).sort_by { |it| [ (it[:order] || 100).to_i, it[:label].to_s ] }
|
|
16
16
|
[ section_key, section.merge(items: items) ]
|
|
17
17
|
end
|
|
18
18
|
|
|
@@ -7,7 +7,15 @@ module AdminSuite
|
|
|
7
7
|
|
|
8
8
|
before_action :require_resource_config!
|
|
9
9
|
before_action :enforce_read_only!, only: %i[new create edit update destroy toggle]
|
|
10
|
-
|
|
10
|
+
# `search` is excluded even though it can receive an `:id`-shaped query
|
|
11
|
+
# param: unlike show/edit/update/destroy, a record has no business
|
|
12
|
+
# participating in this action at all. Without this exclusion, a stray
|
|
13
|
+
# `?id=` would (a) hand an attacker-chosen record to `config.authorize`'s
|
|
14
|
+
# `record:` on an action that should never carry one, and (b) let a
|
|
15
|
+
# denied actor distinguish 404 (bad id) from 403 (denied) -- an
|
|
16
|
+
# existence oracle over `find_friendly_resource!`'s slug/uuid/token
|
|
17
|
+
# lookups, on a resource they have no read access to.
|
|
18
|
+
before_action :set_resource, if: -> { params[:id].present? && !%w[index new create search].include?(action_name) }
|
|
11
19
|
before_action :authorize_admin_suite!
|
|
12
20
|
|
|
13
21
|
helper_method :resource_config, :resource_class, :resource, :collection, :current_portal, :resource_name
|
|
@@ -23,6 +31,26 @@ module AdminSuite
|
|
|
23
31
|
def show
|
|
24
32
|
end
|
|
25
33
|
|
|
34
|
+
# GET /:portal/:resource_name/search?q=term
|
|
35
|
+
#
|
|
36
|
+
# Feeds `searchable_select_controller.js`'s `fetchOptions`, which expects
|
|
37
|
+
# a bare JSON array (not `{results: [...]}`) of objects each carrying
|
|
38
|
+
# `id`/`value` and `name`/`title`/`label`. Matches that contract exactly
|
|
39
|
+
# -- the JS needs no changes.
|
|
40
|
+
#
|
|
41
|
+
# Reuses `FilterBuilder.search_predicate`, the same ILIKE-over-
|
|
42
|
+
# `searchable_fields` logic the index's own search box uses, so this can
|
|
43
|
+
# only ever search the resource's declared `searchable` whitelist --
|
|
44
|
+
# never an arbitrary column supplied via `q`. `require_resource_config!`
|
|
45
|
+
# and `authorize_admin_suite!` (both already-registered before_actions,
|
|
46
|
+
# the latter driven by `AUTHORIZATION_VERBS["search"] = :read` below)
|
|
47
|
+
# gate this exactly like every other action on this controller: unknown
|
|
48
|
+
# resource names 404 before either runs, and a denying `config.authorize`
|
|
49
|
+
# 403s before any query executes.
|
|
50
|
+
def search
|
|
51
|
+
render json: search_results.first(SEARCH_RESULT_LIMIT).map { |record| search_result_json(record) }
|
|
52
|
+
end
|
|
53
|
+
|
|
26
54
|
# GET /:portal/:resource_name/new
|
|
27
55
|
def new
|
|
28
56
|
@resource = resource_class.new
|
|
@@ -129,15 +157,49 @@ module AdminSuite
|
|
|
129
157
|
|
|
130
158
|
private
|
|
131
159
|
|
|
160
|
+
# Hard cap on searchable_select results, regardless of table size or how
|
|
161
|
+
# permissive the resource's `searchable` list is.
|
|
162
|
+
SEARCH_RESULT_LIMIT = 25
|
|
163
|
+
|
|
132
164
|
# Controller action -> authorization verb.
|
|
133
165
|
AUTHORIZATION_VERBS = {
|
|
134
|
-
"index" => :read, "show" => :read,
|
|
166
|
+
"index" => :read, "show" => :read, "search" => :read,
|
|
135
167
|
"new" => :create, "create" => :create,
|
|
136
168
|
"edit" => :update, "update" => :update, "toggle" => :update,
|
|
137
169
|
"destroy" => :destroy,
|
|
138
170
|
"execute_action" => :execute, "bulk_action" => :execute
|
|
139
171
|
}.freeze
|
|
140
172
|
|
|
173
|
+
# Filters the resource's own records by `params[:q]` via the shared
|
|
174
|
+
# `FilterBuilder` predicate. A nil predicate (blank `q`, no index config,
|
|
175
|
+
# no declared `searchable` fields, or `q` shorter than
|
|
176
|
+
# `FilterBuilder::MIN_SEARCH_LENGTH`) means no results at all here --
|
|
177
|
+
# deliberately *not* the "fall back to unfiltered scope" behavior
|
|
178
|
+
# `FilterBuilder#apply_search` uses for the index filter box. A raw JSON
|
|
179
|
+
# endpoint must never hand back arbitrary rows just because the query
|
|
180
|
+
# was empty or the resource isn't configured for search.
|
|
181
|
+
#
|
|
182
|
+
# @return [Enumerable]
|
|
183
|
+
def search_results
|
|
184
|
+
predicate = Admin::Base::FilterBuilder.search_predicate(resource_config&.index_config, params[:q])
|
|
185
|
+
return [] unless predicate
|
|
186
|
+
|
|
187
|
+
conditions, search_term = predicate
|
|
188
|
+
resource_class.where(conditions, search: search_term)
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# @param record [Object]
|
|
192
|
+
# @return [Hash]
|
|
193
|
+
def search_result_json(record)
|
|
194
|
+
{ id: record.id, name: search_result_label(record) }
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def search_result_label(record)
|
|
198
|
+
return record.name if record.respond_to?(:name) && record.name.present?
|
|
199
|
+
return record.title if record.respond_to?(:title) && record.title.present?
|
|
200
|
+
record.to_s
|
|
201
|
+
end
|
|
202
|
+
|
|
141
203
|
# Enforces the host's `config.authorize` hook. Nil hook = allowed
|
|
142
204
|
# (authentication remains the gate). Falsy return = 403.
|
|
143
205
|
#
|
|
@@ -200,7 +262,14 @@ module AdminSuite
|
|
|
200
262
|
end
|
|
201
263
|
|
|
202
264
|
@resource = klass.find(id)
|
|
203
|
-
rescue
|
|
265
|
+
rescue StandardError => e
|
|
266
|
+
# A bare `rescue ActiveRecord::RecordNotFound` raises `NameError`
|
|
267
|
+
# *while handling* whatever the real exception was, in a host without
|
|
268
|
+
# ActiveRecord loaded (e.g. `column_names` above failing on a PORO
|
|
269
|
+
# model) -- destroying the original diagnostic. Only recover the one
|
|
270
|
+
# case this method actually knows how to recover from.
|
|
271
|
+
raise unless defined?(ActiveRecord::RecordNotFound) && e.is_a?(ActiveRecord::RecordNotFound)
|
|
272
|
+
|
|
204
273
|
@resource = find_friendly_resource!(klass, id, columns)
|
|
205
274
|
end
|
|
206
275
|
|
|
@@ -215,12 +284,75 @@ module AdminSuite
|
|
|
215
284
|
def filtered_collection
|
|
216
285
|
return resource_class.all unless resource_config&.index_config
|
|
217
286
|
|
|
218
|
-
Admin::Base::FilterBuilder.new(resource_config, params).apply(resource_class.all)
|
|
287
|
+
scope = Admin::Base::FilterBuilder.new(resource_config, params).apply(resource_class.all)
|
|
288
|
+
apply_index_includes(scope)
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
# Applies the index's `includes:` DSL option (see
|
|
292
|
+
# `Admin::Base::Resource::IndexConfig#includes`) to the filtered scope.
|
|
293
|
+
# Only when the scope actually responds to `#includes` -- the PORO
|
|
294
|
+
# `Relation` test doubles used throughout this gem's own test suite
|
|
295
|
+
# don't, and a host's own non-AR scope object may not either -- so this
|
|
296
|
+
# skips silently rather than raising. `.includes` itself can raise for
|
|
297
|
+
# a bad/renamed/typo'd association name once the scope is a real AR
|
|
298
|
+
# relation; that must degrade the index to an unoptimized-but-working
|
|
299
|
+
# page, not 500 it, so it's logged and swallowed the same way the
|
|
300
|
+
# chart panel's bad `type:`/`data` values are (see
|
|
301
|
+
# `app/views/admin_suite/panels/_chart.html.erb`).
|
|
302
|
+
#
|
|
303
|
+
# @param scope [Object] the filtered collection
|
|
304
|
+
# @return [Object] the scope, with associations eager-loaded when possible
|
|
305
|
+
def apply_index_includes(scope)
|
|
306
|
+
includes_list = resource_config.index_config.includes_list
|
|
307
|
+
return scope if includes_list.blank?
|
|
308
|
+
return scope unless scope.respond_to?(:includes)
|
|
309
|
+
|
|
310
|
+
scope.includes(*includes_list)
|
|
311
|
+
rescue StandardError => e
|
|
312
|
+
Rails.logger&.warn(
|
|
313
|
+
"AdminSuite: #{resource_class}'s index `includes(#{includes_list.inspect})` raised " \
|
|
314
|
+
"#{e.class}: #{e.message}; rendering the index without eager loading."
|
|
315
|
+
)
|
|
316
|
+
scope
|
|
219
317
|
end
|
|
220
318
|
|
|
319
|
+
# Max a request can push the index's per-page count to, regardless of
|
|
320
|
+
# what `per_page` the query string carries -- `per_page` is user-supplied
|
|
321
|
+
# (a plain query param), so this exists to stop `?per_page=999999` from
|
|
322
|
+
# turning the index into an unbounded query.
|
|
323
|
+
MAX_PER_PAGE = 100
|
|
324
|
+
|
|
221
325
|
def paginate_collection(scope)
|
|
222
|
-
|
|
223
|
-
|
|
326
|
+
dsl_per_page = resource_config&.index_config&.per_page || 25
|
|
327
|
+
# Pagy 9.x's vars key is `limit:`, not `items:` -- the pre-existing
|
|
328
|
+
# `items:` call silently did nothing (pagy fell through to its own
|
|
329
|
+
# `DEFAULT[:limit]` of 20), so every resource's `paginate(n)` DSL
|
|
330
|
+
# value was already being ignored before this task. Fixed here since
|
|
331
|
+
# this task's clamp is meaningless without it.
|
|
332
|
+
pagy(scope, limit: clamped_per_page(dsl_per_page))
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
# Resolves the effective per-page count for the index from the
|
|
336
|
+
# `per_page` query param, clamped to `MAX_PER_PAGE` and falling back to
|
|
337
|
+
# the DSL's `paginate(n)` value (`dsl_per_page`) whenever the param is
|
|
338
|
+
# absent or not a usable positive integer.
|
|
339
|
+
#
|
|
340
|
+
# `per_page` is the most directly attacker-influenceable input this
|
|
341
|
+
# phase adds, so every shape it can arrive in is handled without
|
|
342
|
+
# raising: missing (nil), non-numeric ("abc"), zero, negative, an
|
|
343
|
+
# array (`per_page[]=1`, which Rails hands back as a plain Array, not
|
|
344
|
+
# a String -- `Integer(Array)` raises `TypeError`), and absurdly large
|
|
345
|
+
# (clamped, never passed through to the query).
|
|
346
|
+
#
|
|
347
|
+
# @param dsl_per_page [Integer] the resource's `paginate(n)` value
|
|
348
|
+
# @return [Integer]
|
|
349
|
+
def clamped_per_page(dsl_per_page)
|
|
350
|
+
value = Integer(params[:per_page])
|
|
351
|
+
return dsl_per_page if value <= 0
|
|
352
|
+
|
|
353
|
+
value.clamp(..MAX_PER_PAGE)
|
|
354
|
+
rescue ArgumentError, TypeError
|
|
355
|
+
dsl_per_page
|
|
224
356
|
end
|
|
225
357
|
|
|
226
358
|
def calculate_stats(scope)
|