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.
Files changed (70) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +210 -1
  3. data/app/assets/vendor/chart.umd.min.js +14 -0
  4. data/app/assets/vendor/easymde.min.css +7 -0
  5. data/app/assets/vendor/easymde.min.js +7 -0
  6. data/app/controllers/admin_suite/application_controller.rb +26 -47
  7. data/app/controllers/admin_suite/portals_controller.rb +2 -2
  8. data/app/controllers/admin_suite/resources_controller.rb +138 -6
  9. data/app/helpers/admin_suite/base_helper.rb +232 -391
  10. data/app/javascript/admin_suite_application.js +3 -0
  11. data/app/javascript/controllers/admin_suite/chart_controller.js +173 -0
  12. data/app/javascript/controllers/admin_suite/markdown_editor_controller.js +21 -2
  13. data/app/views/admin_suite/panels/_chart.html.erb +158 -18
  14. data/app/views/admin_suite/panels/_stat.html.erb +10 -0
  15. data/app/views/admin_suite/resources/index.html.erb +31 -82
  16. data/app/views/admin_suite/shared/_pagination.html.erb +74 -0
  17. data/app/views/admin_suite/shared/_sidebar.html.erb +15 -9
  18. data/app/views/layouts/admin_suite/application.html.erb +11 -3
  19. data/config/routes.rb +3 -0
  20. data/lib/admin/base/action_executor.rb +19 -51
  21. data/lib/admin/base/filter_builder.rb +48 -5
  22. data/lib/admin/base/resource.rb +92 -17
  23. data/lib/admin_suite/configuration.rb +32 -3
  24. data/lib/admin_suite/definition_loader.rb +194 -0
  25. data/lib/admin_suite/deprecation.rb +48 -0
  26. data/lib/admin_suite/engine.rb +55 -76
  27. data/lib/admin_suite/host_autoload_policy.rb +132 -0
  28. data/lib/admin_suite/legacy_custom_renderer_procs.rb +29 -0
  29. data/lib/admin_suite/portal_definition.rb +11 -0
  30. data/lib/admin_suite/renderer.rb +133 -0
  31. data/lib/admin_suite/renderer_registry.rb +81 -0
  32. data/lib/admin_suite/renderers/code_renderer.rb +15 -0
  33. data/lib/admin_suite/renderers/json_renderer.rb +15 -0
  34. data/lib/admin_suite/renderers/key_value_renderer.rb +39 -0
  35. data/lib/admin_suite/renderers/legacy_gleania.rb +232 -0
  36. data/lib/admin_suite/renderers/table_from_renderer.rb +22 -0
  37. data/lib/admin_suite/section_definition.rb +42 -0
  38. data/lib/admin_suite/ui/dashboard_definition.rb +6 -0
  39. data/lib/admin_suite/ui/field_renderer_registry.rb +31 -4
  40. data/lib/admin_suite/ui/form_field_renderer.rb +1 -7
  41. data/lib/admin_suite/ui/show_formatter_registry.rb +9 -0
  42. data/lib/admin_suite/ui/show_value_formatter.rb +7 -3
  43. data/lib/admin_suite/version.rb +1 -1
  44. data/lib/admin_suite.rb +48 -0
  45. data/lib/generators/admin_suite/install/templates/admin_suite.rb +0 -4
  46. data/test/controllers/resources_controller_test.rb +76 -1
  47. data/test/integration/association_linking_test.rb +292 -0
  48. data/test/integration/chart_panel_test.rb +491 -0
  49. data/test/integration/dashboard_test.rb +9 -2
  50. data/test/integration/index_table_test.rb +289 -0
  51. data/test/integration/layout_assets_test.rb +112 -0
  52. data/test/integration/navigation_sections_test.rb +62 -0
  53. data/test/integration/pagination_and_stats_test.rb +152 -0
  54. data/test/integration/searchable_select_search_test.rb +368 -0
  55. data/test/integration/show_hide_blank_test.rb +195 -0
  56. data/test/integration/toggle_test.rb +106 -0
  57. data/test/lib/action_executor_redirect_test.rb +41 -0
  58. data/test/lib/builtin_renderers_test.rb +202 -0
  59. data/test/lib/definition_loader_test.rb +276 -0
  60. data/test/lib/engine_defaults_test.rb +39 -0
  61. data/test/lib/form_field_renderer_test.rb +136 -0
  62. data/test/lib/format_table_cell_test.rb +49 -0
  63. data/test/lib/index_includes_test.rb +223 -0
  64. data/test/lib/legacy_renderer_deprecation_test.rb +42 -0
  65. data/test/lib/renderer_test.rb +237 -0
  66. data/test/lib/resource_exportable_deprecation_test.rb +47 -0
  67. data/test/lib/show_value_formatter_test.rb +88 -0
  68. data/test/lib/zeitwerk_integration_test.rb +28 -64
  69. data/test/test_helper.rb +121 -5
  70. metadata +62 -5
@@ -81,37 +81,14 @@ module AdminSuite
81
81
  #
82
82
  # @return [void]
83
83
  def ensure_resources_loaded!
84
- require "admin/base/resource" unless defined?(Admin::Base::Resource)
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
- globs = Array(AdminSuite.config.portal_globs).flat_map { |g| Dir[g] }.uniq
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
- if Rails.env.development?
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] ||= { label: section.to_s.humanize, items: [] }
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
- before_action :set_resource, if: -> { params[:id].present? && !%w[index new create].include?(action_name) }
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 ActiveRecord::RecordNotFound
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
- per_page = resource_config&.index_config&.per_page || 25
223
- pagy(scope, items: per_page)
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)