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
@@ -60,20 +60,26 @@
60
60
 
61
61
  <% if active %>
62
62
  <div class="mt-1 ml-11 space-y-0.5 text-sm">
63
- <% portal[:sections].sort_by { |(_k, s)| s[:label].to_s }.each do |_section_key, section| %>
63
+ <% portal[:sections].sort_by { |(_k, s)| [ (s[:order] || 100).to_i, s[:label].to_s ] }.each do |_section_key, section| %>
64
64
  <div class="text-xs font-medium text-white/70 uppercase tracking-wider px-3 pt-2 pb-1">
65
65
  <%= section[:label] %>
66
66
  </div>
67
- <% section[:items].sort_by { |it| [ (it[:order] || 100).to_i, it[:label].to_s ] }.each do |item| %>
68
- <% item_active = request.path.start_with?(item[:path].to_s) %>
69
- <%= link_to item[:path],
70
- class: "flex items-center gap-2 px-3 py-1.5 rounded #{item_active ? "text-white bg-white/20" : "text-white/90 hover:bg-white/10 hover:text-white"}",
71
- data: { turbo_frame: "_top" } do %>
72
- <% if item[:icon].present? %>
73
- <%= admin_suite_icon(item[:icon], class: "w-3.5 h-3.5 text-white/90") %>
67
+ <% if section[:items].any? %>
68
+ <% section[:items].sort_by { |it| [ (it[:order] || 100).to_i, it[:label].to_s ] }.each do |item| %>
69
+ <% item_active = request.path.start_with?(item[:path].to_s) %>
70
+ <%= link_to item[:path],
71
+ class: "flex items-center gap-2 px-3 py-1.5 rounded #{item_active ? "text-white bg-white/20" : "text-white/90 hover:bg-white/10 hover:text-white"}",
72
+ data: { turbo_frame: "_top" } do %>
73
+ <% if item[:icon].present? %>
74
+ <%= admin_suite_icon(item[:icon], class: "w-3.5 h-3.5 text-white/90") %>
75
+ <% end %>
76
+ <span class="truncate"><%= item[:label] %></span>
74
77
  <% end %>
75
- <span class="truncate"><%= item[:label] %></span>
76
78
  <% end %>
79
+ <% else %>
80
+ <div class="px-3 py-1.5 text-xs text-white/50 italic">
81
+ No resources in this section yet.
82
+ </div>
77
83
  <% end %>
78
84
  <% end %>
79
85
  </div>
@@ -29,9 +29,17 @@
29
29
 
30
30
  <%= stylesheet_link_tag "rouge", "data-turbo-track": "reload" %>
31
31
 
32
- <%# EasyMDE Markdown Editor %>
33
- <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/easymde@2.18.0/dist/easymde.min.css" data-turbo-track="reload">
34
- <script src="https://cdn.jsdelivr.net/npm/easymde@2.18.0/dist/easymde.min.js" data-turbo-track="reload"></script>
32
+ <%# EasyMDE Markdown Editor: vendored (see app/assets/vendor), loaded only
33
+ when the page actually renders a :markdown field (see
34
+ AdminSuite::UI::FieldRendererRegistry's :markdown handler, which sets
35
+ this content_for). %>
36
+ <%= yield :easymde_assets %>
37
+
38
+ <%# Chart.js: vendored (see app/assets/vendor), loaded only when the page
39
+ actually renders a chart panel with data (see
40
+ app/views/admin_suite/panels/_chart.html.erb, which sets this
41
+ content_for). %>
42
+ <%= yield :chart_assets %>
35
43
 
36
44
  <% if respond_to?(:javascript_importmap_tags) %>
37
45
  <%= javascript_importmap_tags %>
data/config/routes.rb CHANGED
@@ -14,6 +14,9 @@ AdminSuite::Engine.routes.draw do
14
14
  scope ":portal/:resource_name" do
15
15
  get "/", to: "resources#index", as: :resources
16
16
  get "/new", to: "resources#new", as: :new_resource
17
+ # Must precede "/:id" -- a literal segment match wins over the dynamic
18
+ # one only because Rails tries routes in declaration order.
19
+ get "/search", to: "resources#search", as: :search_resources
17
20
  post "/", to: "resources#create"
18
21
  get "/:id", to: "resources#show", as: :resource
19
22
  get "/:id/edit", to: "resources#edit", as: :edit_resource
@@ -116,14 +116,20 @@ module Admin
116
116
  "#{action.label} completed successfully",
117
117
  redirect_url: redirect_url_for_action(action, action_result)
118
118
  )
119
- rescue ActiveRecord::RecordInvalid => e
120
- failure_result("Validation failed: #{e.record.errors.full_messages.join(', ')}")
121
- rescue AASM::InvalidTransition => e
122
- failure_result("Invalid state transition: #{e.message}")
119
+ rescue StandardError => e
120
+ # Neither constant is referenced unless the host actually defines it:
121
+ # a bare `rescue SomeGem::Error` raises NameError *while handling* the
122
+ # original exception in hosts without that gem, masking the real error.
123
+ if defined?(ActiveRecord::RecordInvalid) && e.is_a?(ActiveRecord::RecordInvalid)
124
+ failure_result("Validation failed: #{e.record.errors.full_messages.join(', ')}")
125
+ elsif e.class.name.to_s.include?("InvalidTransition")
126
+ failure_result("Invalid state transition: #{e.message}")
127
+ else
128
+ raise
129
+ end
123
130
  end
124
131
 
125
132
  def redirect_url_for_action(action, action_result)
126
- return nil unless action.name.to_sym == :duplicate
127
133
  return nil unless action_result.respond_to?(:persisted?) && action_result.persisted?
128
134
  return nil unless resource_class.respond_to?(:portal_name) && resource_class.respond_to?(:resource_name_plural)
129
135
 
@@ -188,55 +194,17 @@ module Admin
188
194
  nil
189
195
  end
190
196
 
197
+ # Loads action handler files configured via `AdminSuite.config.action_globs`.
198
+ #
199
+ # Kept as a thin wrapper (rather than calling DefinitionLoader directly
200
+ # from `find_handler_class`) because `test/lib/action_executor_test.rb`
201
+ # exercises this exact method name against `self.class.handlers_loaded`.
202
+ #
203
+ # @return [void]
191
204
  def load_action_handlers_for_admin_suite!
192
205
  return unless defined?(AdminSuite)
193
206
 
194
- # Track whether we've already loaded handlers to avoid expensive repeated globs.
195
- # In development, this flag is reset by the Rails reloader (see engine.rb).
196
- # In production/test, it persists for the process lifetime.
197
- return if self.class.handlers_loaded
198
-
199
- files = Array(AdminSuite.config.action_globs).flat_map { |g| Dir[g] }.uniq
200
-
201
- # Set the flag even if no files found - we've done the glob and shouldn't repeat it
202
- if files.empty?
203
- self.class.handlers_loaded = true
204
- return
205
- end
206
-
207
- files.each do |file|
208
- begin
209
- if Rails.env.development?
210
- load file
211
- else
212
- require file
213
- end
214
- rescue StandardError, ScriptError => e
215
- log_action_handler_load_error(file, e)
216
-
217
- # Fail fast in dev/test so broken handler files are immediately discoverable.
218
- raise if Rails.env.development? || Rails.env.test?
219
- end
220
- end
221
-
222
- # We attempted to load the configured handlers. Avoid repeating expensive globs
223
- # and file loads for the rest of the process lifetime.
224
- self.class.handlers_loaded = true
225
- end
226
-
227
- def log_action_handler_load_error(file, error)
228
- message = "[AdminSuite] Failed to load action handler file #{file}: #{error.class}: #{error.message}"
229
-
230
- if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
231
- Rails.logger.error(message)
232
-
233
- backtrace = Array(error.backtrace).take(20).join("\n")
234
- Rails.logger.error(backtrace) unless backtrace.empty?
235
- else
236
- warn(message)
237
- end
238
- rescue StandardError
239
- nil
207
+ AdminSuite::DefinitionLoader.load!(:actions)
240
208
  end
241
209
  end
242
210
  end
@@ -3,6 +3,13 @@
3
3
  module Admin
4
4
  module Base
5
5
  class FilterBuilder
6
+ # Matches the index search box's existing floor: below this,
7
+ # `apply_search` leaves the scope untouched rather than emitting a
8
+ # near-useless single/double-character ILIKE. `search_predicate`
9
+ # (below) is where this now lives, since it's shared with
10
+ # `ResourcesController#search`.
11
+ MIN_SEARCH_LENGTH = 3
12
+
6
13
  attr_reader :resource_class, :params
7
14
 
8
15
  def initialize(resource_class, params)
@@ -26,6 +33,43 @@ module Admin
26
33
  params.permit(*permitted_keys).to_h.symbolize_keys
27
34
  end
28
35
 
36
+ # Builds the shared ILIKE-over-searchable_fields predicate -- the exact
37
+ # logic `apply_search` below uses for the index's own search box --
38
+ # extracted so `ResourcesController#search` (the searchable_select
39
+ # endpoint) can reuse it verbatim instead of growing a second,
40
+ # divergent search path over the same whitelist.
41
+ #
42
+ # The term is always returned as a value to be bound (`"%term%"`),
43
+ # never interpolated into the SQL text -- only the (whitelisted) field
44
+ # *names* from `index_config.searchable_fields` go into the conditions
45
+ # string, exactly as `apply_search` has always built it. That keeps
46
+ # this safe against SQL metacharacters in `term`: whatever a caller
47
+ # passes ends up as a single bound parameter, not part of the query
48
+ # text.
49
+ #
50
+ # Returns nil when no predicate should be applied at all: no index
51
+ # config, no declared `searchable` fields, a blank term, or a term
52
+ # shorter than `MIN_SEARCH_LENGTH`. What a nil predicate *means* is
53
+ # left to the caller -- `apply_search` treats it as "don't filter"
54
+ # (falls back to the unfiltered scope, correct for an index filter
55
+ # box), while `ResourcesController#search` treats it as "no results"
56
+ # (correct for a raw JSON data endpoint, which must never hand back
57
+ # the unfiltered table just because the query was blank/too short or
58
+ # the resource has no searchable fields to check).
59
+ #
60
+ # @param index_config [Admin::Base::Resource::IndexConfig, nil]
61
+ # @param term [String, nil]
62
+ # @return [Array(String, String), nil] [sql_conditions, "%term%"]
63
+ def self.search_predicate(index_config, term)
64
+ return nil if index_config.nil?
65
+ return nil if term.blank?
66
+ return nil if index_config.searchable_fields.empty?
67
+ return nil if term.to_s.length < MIN_SEARCH_LENGTH
68
+
69
+ conditions = index_config.searchable_fields.map { |field| "#{field} ILIKE :search" }.join(" OR ")
70
+ [ conditions, "%#{term}%" ]
71
+ end
72
+
29
73
  private
30
74
 
31
75
  def index_config
@@ -34,12 +78,11 @@ module Admin
34
78
 
35
79
  def apply_search(scope)
36
80
  return scope unless index_config
37
- return scope if params[:search].blank?
38
- return scope if index_config.searchable_fields.empty?
39
- return scope if params[:search].to_s.length < 3
40
81
 
41
- search_term = "%#{params[:search]}%"
42
- conditions = index_config.searchable_fields.map { |field| "#{field} ILIKE :search" }.join(" OR ")
82
+ predicate = self.class.search_predicate(index_config, params[:search])
83
+ return scope unless predicate
84
+
85
+ conditions, search_term = predicate
43
86
  scope.where(conditions, search: search_term)
44
87
  end
45
88
 
@@ -39,6 +39,13 @@ module Admin
39
39
  # end
40
40
  # end
41
41
  class Resource
42
+ extend AdminSuite::Deprecation
43
+
44
+ EXPORTABLE_DEPRECATION_MESSAGE_FORMAT =
45
+ "AdminSuite: %<resource>s calls `exportable`, which is a deprecated " \
46
+ "no-op and will be removed in 0.6.0. It never actually implemented " \
47
+ "export in any released version — safe to delete the call."
48
+
42
49
  class << self
43
50
  # Model configuration
44
51
  attr_reader :model_class, :portal_name, :section_name, :nav_label, :nav_icon, :nav_order
@@ -55,9 +62,6 @@ module Admin
55
62
  # Actions configuration
56
63
  attr_reader :actions_config
57
64
 
58
- # Export configuration
59
- attr_reader :export_formats
60
-
61
65
  # Sets the model class for this resource
62
66
  #
63
67
  # @param klass [Class] The ActiveRecord model class
@@ -157,14 +161,6 @@ module Admin
157
161
  @actions_config.instance_eval(&block) if block_given?
158
162
  end
159
163
 
160
- # Configures export formats
161
- #
162
- # @param formats [Array<Symbol>] Export formats (:json, :csv)
163
- # @return [void]
164
- def exportable(*formats)
165
- @export_formats = formats
166
- end
167
-
168
164
  # Declares whether the generic resource routes may mutate records.
169
165
  # Read-only resources retain index/show access while the controller
170
166
  # rejects direct requests to every built-in mutation endpoint.
@@ -176,6 +172,32 @@ module Admin
176
172
  @read_only == true
177
173
  end
178
174
 
175
+ # Deprecated no-op; removal targeted at 0.6.0 (moved from the
176
+ # originally-planned 0.5.0 -- pending the gleania/trust_growth host
177
+ # migrations).
178
+ #
179
+ # `exportable` was write-only in every prior release -- it never had
180
+ # a reader and never drove any export behavior -- but hosts still
181
+ # call it from resource-definition bodies (gleania: 30 files;
182
+ # trust_growth: 1). A real removal would raise `NoMethodError` at
183
+ # definition-load time, and in production `DefinitionLoader` logs
184
+ # and swallows that, so the resource just silently vanishes from the
185
+ # admin. Kept as a no-op instead, so those files keep loading.
186
+ #
187
+ # Deliberately does not restore `@export_formats` or any reader --
188
+ # only the harmless no-op comes back.
189
+ #
190
+ # @param _formats [Array<Symbol>] ignored
191
+ # @return [void]
192
+ def exportable(*_formats)
193
+ # `warn_once`, `warn_once_sink` and `reset_deprecation_notices!`
194
+ # come from `AdminSuite::Deprecation`, extended above. Keyed on the
195
+ # resource class itself, so each resource warns independently (and
196
+ # only once) rather than one call anywhere silencing every other
197
+ # resource's first call.
198
+ warn_once(name, format(EXPORTABLE_DEPRECATION_MESSAGE_FORMAT, resource: name))
199
+ end
200
+
179
201
  # Returns the resource name derived from class name
180
202
  #
181
203
  # @return [String]
@@ -252,7 +274,7 @@ module Admin
252
274
  # Index view configuration
253
275
  class IndexConfig
254
276
  attr_reader :searchable_fields, :sortable_fields, :default_sort, :default_sort_direction,
255
- :columns_list, :filters_list, :stats_list, :per_page
277
+ :columns_list, :filters_list, :stats_list, :per_page, :includes_list
256
278
 
257
279
  def initialize
258
280
  @searchable_fields = []
@@ -263,6 +285,7 @@ module Admin
263
285
  @filters_list = []
264
286
  @stats_list = []
265
287
  @per_page = 25
288
+ @includes_list = []
266
289
  end
267
290
 
268
291
  def searchable(*fields)
@@ -279,6 +302,23 @@ module Admin
279
302
  @per_page = count
280
303
  end
281
304
 
305
+ # Kills the index's association N+1 (Task 3 made `belongs_to`
306
+ # columns render as links, so each one now genuinely dereferences
307
+ # the association -- one query per row without this). The
308
+ # controller applies this to the filtered scope only when the
309
+ # scope actually responds to `#includes` -- PORO test doubles and
310
+ # some host scopes don't -- and swallows a bad association name
311
+ # (typo, renamed association) by logging and rendering
312
+ # unoptimized rather than 500ing. `nil` entries (e.g. a stray
313
+ # `includes nil`) are dropped rather than stored and handed to the
314
+ # scope verbatim.
315
+ #
316
+ # @param associations [Array<Symbol, String>]
317
+ # @return [void]
318
+ def includes(*associations)
319
+ @includes_list = associations.flatten.compact
320
+ end
321
+
282
322
  def columns(&block)
283
323
  builder = ColumnsBuilder.new
284
324
  builder.instance_eval(&block) if block_given?
@@ -309,19 +349,19 @@ module Admin
309
349
  @columns << ColumnDefinition.new(
310
350
  name: name,
311
351
  content: content,
312
- render: options[:render],
313
352
  header: options[:header] || name.to_s.humanize,
314
353
  css_class: options[:class],
315
354
  type: options[:type],
316
355
  toggle_field: options[:toggle_field],
317
356
  label_color: options[:label_color],
318
357
  label_size: options[:label_size],
319
- sortable: options[:sortable] || false
358
+ sortable: options[:sortable] || false,
359
+ align: options[:align]
320
360
  )
321
361
  end
322
362
  end
323
363
 
324
- ColumnDefinition = Struct.new(:name, :content, :render, :header, :css_class, :type, :toggle_field, :label_color, :label_size, :sortable, keyword_init: true)
364
+ ColumnDefinition = Struct.new(:name, :content, :header, :css_class, :type, :toggle_field, :label_color, :label_size, :sortable, :align, keyword_init: true)
325
365
 
326
366
  class FiltersBuilder
327
367
  attr_reader :filters
@@ -393,7 +433,8 @@ module Admin
393
433
  variants: options[:variants],
394
434
  label_color: options[:label_color],
395
435
  label_size: options[:label_size],
396
- parent_field: options[:parent_field]
436
+ parent_field: options[:parent_field],
437
+ resource: options[:resource]
397
438
  )
398
439
  end
399
440
 
@@ -420,6 +461,12 @@ module Admin
420
461
  :collection, :create_url, :accept, :rows, :readonly,
421
462
  :if_condition, :unless_condition, :multiple, :creatable,
422
463
  :preview, :variants, :label_color, :label_size, :parent_field,
464
+ # `resource:` (e.g. `resource: :companies`) lets a `searchable_select`
465
+ # field resolve its search URL automatically against the gem's own
466
+ # search endpoint, rather than requiring every host to supply a
467
+ # `collection:` String pointing at a hand-rolled route. See
468
+ # `render_searchable_select` in `app/helpers/admin_suite/base_helper.rb`.
469
+ :resource,
423
470
  keyword_init: true
424
471
  )
425
472
 
@@ -470,7 +517,25 @@ module Admin
470
517
 
471
518
  private
472
519
 
520
+ # Struct members already given a dedicated home above. Everything
521
+ # else in the panel/section DSL call (`source:`, `empty:`,
522
+ # `language:`, plus `columns:` again — see below) is forwarded
523
+ # verbatim to the renderer via `ShowSectionDefinition#options`.
524
+ RESERVED_SECTION_OPTION_KEYS = %i[
525
+ fields association limit render title display link_to resource
526
+ paginate pagination per_page collapsible collapsed hide_blank
527
+ ].freeze
528
+
473
529
  def build_section(name, options)
530
+ # `columns:` is deliberately NOT excluded here even though it also
531
+ # populates the dedicated `columns` member below (used today by
532
+ # association-table display). Built-in renderers like
533
+ # `:table_from` only ever see `section.options`, not the section
534
+ # itself, so `columns:` has to reach `options[:columns]` too or a
535
+ # DSL author's explicit column list would be silently dropped in
536
+ # favor of the renderer's own inference.
537
+ leftover_options = options.except(*RESERVED_SECTION_OPTION_KEYS)
538
+
474
539
  ShowSectionDefinition.new(
475
540
  name: name,
476
541
  fields: options[:fields] || [],
@@ -485,7 +550,15 @@ module Admin
485
550
  paginate: options[:paginate] || options[:pagination] || false,
486
551
  per_page: options[:per_page],
487
552
  collapsible: options[:collapsible] || false,
488
- collapsed: options[:collapsed] || false
553
+ collapsed: options[:collapsed] || false,
554
+ # `fields:`-only rows whose value is blank (nil or `.empty?`)
555
+ # are omitted entirely -- see `show_value_blank?` in
556
+ # `AdminSuite::BaseHelper`, which reads this member. Default
557
+ # `false`: no existing show page changes unless it opts in.
558
+ # Deliberately NOT `value.blank?` -- that's true for `false`
559
+ # and `0`, both of which are meaningful, rendered values today.
560
+ hide_blank: options[:hide_blank] || false,
561
+ options: leftover_options
489
562
  )
490
563
  end
491
564
  end
@@ -493,6 +566,8 @@ module Admin
493
566
  ShowSectionDefinition = Struct.new(
494
567
  :name, :fields, :association, :limit, :render, :title,
495
568
  :display, :columns, :link_to, :resource, :paginate, :per_page, :collapsible, :collapsed,
569
+ :hide_blank,
570
+ :options,
496
571
  keyword_init: true
497
572
  )
498
573
 
@@ -17,7 +17,6 @@ module AdminSuite
17
17
  :action_globs,
18
18
  :portal_globs,
19
19
  :dashboard_globs,
20
- :portals,
21
20
  :custom_renderers,
22
21
  :icon_renderer,
23
22
  :docs_url,
@@ -25,7 +24,6 @@ module AdminSuite
25
24
  :partials,
26
25
  :theme,
27
26
  :host_stylesheet,
28
- :tailwind_cdn,
29
27
  :root_dashboard_title,
30
28
  :root_dashboard_description,
31
29
  :root_dashboard_definition,
@@ -33,6 +31,22 @@ module AdminSuite
33
31
  :on_action_executed,
34
32
  :resolve_action_handler
35
33
 
34
+ attr_reader :portals
35
+
36
+ # Records that the host explicitly assigned portals (even to `{}`), so
37
+ # the engine's built-in defaults are never re-applied over explicit
38
+ # host intent. See #portals_configured?.
39
+ def portals=(value)
40
+ @portals_configured = true
41
+ @portals = value
42
+ end
43
+
44
+ # True once the host has assigned `config.portals` itself, distinct
45
+ # from the engine having applied its own defaults.
46
+ def portals_configured?
47
+ @portals_configured == true
48
+ end
49
+
36
50
  def initialize
37
51
  @authenticate = nil
38
52
  @current_actor = nil
@@ -49,6 +63,7 @@ module AdminSuite
49
63
  @portal_globs = []
50
64
  @dashboard_globs = []
51
65
  @portals = {}
66
+ @portals_configured = false
52
67
  @custom_renderers = {}
53
68
  @icon_renderer = nil
54
69
  @docs_url = nil
@@ -56,7 +71,6 @@ module AdminSuite
56
71
  @partials = {}
57
72
  @theme = { primary: :indigo, secondary: :purple }
58
73
  @host_stylesheet = nil
59
- @tailwind_cdn = true
60
74
  @root_dashboard_title = nil
61
75
  @root_dashboard_description = nil
62
76
  @root_dashboard_definition = nil
@@ -64,5 +78,20 @@ module AdminSuite
64
78
  @on_action_executed = nil
65
79
  @resolve_action_handler = nil
66
80
  end
81
+
82
+ # Sets the built-in default portals without marking portals as
83
+ # host-configured.
84
+ #
85
+ # Engine-internal: public so `AdminSuite::Engine.apply_default_portals!`
86
+ # can call it directly, without reaching past `Configuration`'s privacy
87
+ # via `send`. Not part of the host-facing configuration API -- a host
88
+ # app should never call this itself; use `config.portals = { ... }` (or
89
+ # `config.portals = {}` to suppress the built-in defaults). This
90
+ # deliberately bypasses the public `portals=` writer so gem-applied
91
+ # defaults stay distinguishable from an explicit host assignment
92
+ # (including an explicit `{}` meant to suppress defaults).
93
+ def default_portals!(value)
94
+ @portals = value
95
+ end
67
96
  end
68
97
  end