admin_suite 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +88 -0
  3. data/app/assets/vendor/easymde.min.css +7 -0
  4. data/app/assets/vendor/easymde.min.js +7 -0
  5. data/app/controllers/admin_suite/application_controller.rb +26 -47
  6. data/app/controllers/admin_suite/portals_controller.rb +2 -2
  7. data/app/controllers/admin_suite/resources_controller.rb +8 -1
  8. data/app/helpers/admin_suite/base_helper.rb +49 -385
  9. data/app/javascript/controllers/admin_suite/markdown_editor_controller.js +21 -2
  10. data/app/views/admin_suite/panels/_chart.html.erb +3 -2
  11. data/app/views/admin_suite/panels/_stat.html.erb +10 -0
  12. data/app/views/admin_suite/resources/index.html.erb +7 -77
  13. data/app/views/admin_suite/shared/_pagination.html.erb +74 -0
  14. data/app/views/admin_suite/shared/_sidebar.html.erb +1 -1
  15. data/app/views/layouts/admin_suite/application.html.erb +5 -3
  16. data/lib/admin/base/action_executor.rb +19 -51
  17. data/lib/admin/base/resource.rb +53 -14
  18. data/lib/admin_suite/configuration.rb +28 -3
  19. data/lib/admin_suite/definition_loader.rb +194 -0
  20. data/lib/admin_suite/deprecation.rb +48 -0
  21. data/lib/admin_suite/engine.rb +55 -76
  22. data/lib/admin_suite/host_autoload_policy.rb +132 -0
  23. data/lib/admin_suite/legacy_custom_renderer_procs.rb +29 -0
  24. data/lib/admin_suite/portal_definition.rb +11 -0
  25. data/lib/admin_suite/renderer.rb +133 -0
  26. data/lib/admin_suite/renderer_registry.rb +70 -0
  27. data/lib/admin_suite/renderers/code_renderer.rb +15 -0
  28. data/lib/admin_suite/renderers/json_renderer.rb +15 -0
  29. data/lib/admin_suite/renderers/key_value_renderer.rb +39 -0
  30. data/lib/admin_suite/renderers/legacy_gleania.rb +230 -0
  31. data/lib/admin_suite/renderers/table_from_renderer.rb +22 -0
  32. data/lib/admin_suite/section_definition.rb +42 -0
  33. data/lib/admin_suite/ui/field_renderer_registry.rb +31 -4
  34. data/lib/admin_suite/ui/form_field_renderer.rb +1 -7
  35. data/lib/admin_suite/ui/show_formatter_registry.rb +9 -0
  36. data/lib/admin_suite/ui/show_value_formatter.rb +7 -3
  37. data/lib/admin_suite/version.rb +1 -1
  38. data/lib/admin_suite.rb +37 -0
  39. data/lib/generators/admin_suite/install/templates/admin_suite.rb +0 -4
  40. data/test/controllers/resources_controller_test.rb +76 -1
  41. data/test/integration/dashboard_test.rb +40 -0
  42. data/test/integration/layout_assets_test.rb +112 -0
  43. data/test/integration/navigation_sections_test.rb +41 -0
  44. data/test/integration/pagination_and_stats_test.rb +152 -0
  45. data/test/lib/action_executor_redirect_test.rb +41 -0
  46. data/test/lib/builtin_renderers_test.rb +202 -0
  47. data/test/lib/definition_loader_test.rb +264 -0
  48. data/test/lib/engine_defaults_test.rb +39 -0
  49. data/test/lib/form_field_renderer_test.rb +64 -0
  50. data/test/lib/legacy_renderer_deprecation_test.rb +35 -0
  51. data/test/lib/renderer_test.rb +221 -0
  52. data/test/lib/resource_exportable_deprecation_test.rb +39 -0
  53. data/test/lib/show_value_formatter_test.rb +88 -0
  54. data/test/lib/zeitwerk_integration_test.rb +28 -64
  55. data/test/test_helper.rb +77 -0
  56. metadata +28 -1
@@ -48,11 +48,12 @@
48
48
  <% if @stats.present? %>
49
49
  <div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
50
50
  <% @stats.each do |stat| %>
51
- <% color = stat[:color] || :slate %>
52
- <div class="bg-white rounded-xl p-4 border border-slate-200">
53
- <div class="text-2xl font-bold text-<%= color %>-600"><%= stat[:value] %></div>
54
- <div class="text-sm text-slate-500"><%= stat[:name] %></div>
55
- </div>
51
+ <% stat_panel = AdminSuite::UI::PanelDefinition.new(
52
+ type: :stat,
53
+ title: stat[:name],
54
+ options: { value: stat[:value], color: stat[:color] || :slate, variant: :mini }
55
+ ) %>
56
+ <%= render "admin_suite/panels/stat", panel: stat_panel %>
56
57
  <% end %>
57
58
  </div>
58
59
  <% end %>
@@ -217,78 +218,7 @@
217
218
  </div>
218
219
 
219
220
  <!-- Pagination -->
220
- <% if @pagy.pages > 1 %>
221
- <div class="px-6 py-4 border-t border-slate-200">
222
- <div class="flex items-center justify-between">
223
- <div class="text-sm text-slate-500">
224
- Showing <span class="font-medium text-slate-700"><%= @pagy.from %></span> to
225
- <span class="font-medium text-slate-700"><%= @pagy.to %></span> of
226
- <span class="font-medium text-slate-700"><%= number_with_delimiter(@pagy.count) %></span> results
227
- </div>
228
-
229
- <nav class="flex items-center gap-1">
230
- <% if @pagy.prev %>
231
- <%= link_to url_for(params.permit!.merge(page: @pagy.prev)),
232
- class: "inline-flex items-center gap-1 px-3 py-1.5 text-sm font-medium text-slate-700 bg-white border border-slate-300 rounded-lg hover:bg-slate-50 transition-colors" do %>
233
- <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
234
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
235
- </svg>
236
- Prev
237
- <% end %>
238
- <% else %>
239
- <span class="inline-flex items-center gap-1 px-3 py-1.5 text-sm font-medium text-slate-400 bg-slate-100 border border-slate-200 rounded-lg cursor-not-allowed">
240
- <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
241
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
242
- </svg>
243
- Prev
244
- </span>
245
- <% end %>
246
-
247
- <div class="hidden sm:flex items-center gap-1 mx-2">
248
- <% @pagy.series.each do |item| %>
249
- <% if item == :gap %>
250
- <span class="px-2 py-1 text-sm text-slate-400">…</span>
251
- <% elsif item.is_a?(Integer) %>
252
- <%= link_to item, url_for(params.permit!.merge(page: item)),
253
- class: "px-3 py-1.5 text-sm font-medium text-slate-700 bg-white border border-slate-300 rounded-lg hover:bg-slate-50 transition-colors" %>
254
- <% elsif item.is_a?(String) %>
255
- <span class="px-3 py-1.5 text-sm font-medium text-white rounded-lg" style="background: var(--admin-suite-primary); border: 1px solid var(--admin-suite-primary);">
256
- <%= item %>
257
- </span>
258
- <% end %>
259
- <% end %>
260
- </div>
261
-
262
- <span class="sm:hidden px-3 py-1.5 text-sm text-slate-500">
263
- Page <%= @pagy.page %> of <%= @pagy.pages %>
264
- </span>
265
-
266
- <% if @pagy.next %>
267
- <%= link_to url_for(params.permit!.merge(page: @pagy.next)),
268
- class: "inline-flex items-center gap-1 px-3 py-1.5 text-sm font-medium text-slate-700 bg-white border border-slate-300 rounded-lg hover:bg-slate-50 transition-colors" do %>
269
- Next
270
- <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
271
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
272
- </svg>
273
- <% end %>
274
- <% else %>
275
- <span class="inline-flex items-center gap-1 px-3 py-1.5 text-sm font-medium text-slate-400 bg-slate-100 border border-slate-200 rounded-lg cursor-not-allowed">
276
- Next
277
- <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
278
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
279
- </svg>
280
- </span>
281
- <% end %>
282
- </nav>
283
- </div>
284
- </div>
285
- <% else %>
286
- <div class="px-6 py-3 border-t border-slate-200">
287
- <div class="text-sm text-slate-500">
288
- Showing <span class="font-medium text-slate-700"><%= @pagy.count %></span> results
289
- </div>
290
- </div>
291
- <% end %>
221
+ <%= render "admin_suite/shared/pagination", pagy: @pagy, page_param: :page %>
292
222
  <% else %>
293
223
  <div class="p-12 text-center">
294
224
  <div class="inline-flex items-center justify-center w-12 h-12 rounded-full bg-slate-100 mb-4">
@@ -0,0 +1,74 @@
1
+ <% page_param = local_assigns.fetch(:page_param, :page) %>
2
+
3
+ <% if pagy.pages > 1 %>
4
+ <div class="px-6 py-4 border-t border-slate-200">
5
+ <div class="flex items-center justify-between">
6
+ <div class="text-sm text-slate-500">
7
+ Showing <span class="font-medium text-slate-700"><%= pagy.from %></span> to
8
+ <span class="font-medium text-slate-700"><%= pagy.to %></span> of
9
+ <span class="font-medium text-slate-700"><%= number_with_delimiter(pagy.count) %></span> results
10
+ </div>
11
+
12
+ <nav class="flex items-center gap-1">
13
+ <% if pagy.prev %>
14
+ <%= link_to url_for(params.permit!.merge(page_param => pagy.prev)),
15
+ class: "inline-flex items-center gap-1 px-3 py-1.5 text-sm font-medium text-slate-700 bg-white border border-slate-300 rounded-lg hover:bg-slate-50 transition-colors" do %>
16
+ <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
17
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
18
+ </svg>
19
+ Prev
20
+ <% end %>
21
+ <% else %>
22
+ <span class="inline-flex items-center gap-1 px-3 py-1.5 text-sm font-medium text-slate-400 bg-slate-100 border border-slate-200 rounded-lg cursor-not-allowed">
23
+ <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
24
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
25
+ </svg>
26
+ Prev
27
+ </span>
28
+ <% end %>
29
+
30
+ <div class="hidden sm:flex items-center gap-1 mx-2">
31
+ <% pagy.series.each do |item| %>
32
+ <% if item == :gap %>
33
+ <span class="px-2 py-1 text-sm text-slate-400">…</span>
34
+ <% elsif item.is_a?(Integer) %>
35
+ <%= link_to item, url_for(params.permit!.merge(page_param => item)),
36
+ class: "px-3 py-1.5 text-sm font-medium text-slate-700 bg-white border border-slate-300 rounded-lg hover:bg-slate-50 transition-colors" %>
37
+ <% elsif item.is_a?(String) %>
38
+ <span class="px-3 py-1.5 text-sm font-medium text-white rounded-lg" style="background: var(--admin-suite-primary); border: 1px solid var(--admin-suite-primary);">
39
+ <%= item %>
40
+ </span>
41
+ <% end %>
42
+ <% end %>
43
+ </div>
44
+
45
+ <span class="sm:hidden px-3 py-1.5 text-sm text-slate-500">
46
+ Page <%= pagy.page %> of <%= pagy.pages %>
47
+ </span>
48
+
49
+ <% if pagy.next %>
50
+ <%= link_to url_for(params.permit!.merge(page_param => pagy.next)),
51
+ class: "inline-flex items-center gap-1 px-3 py-1.5 text-sm font-medium text-slate-700 bg-white border border-slate-300 rounded-lg hover:bg-slate-50 transition-colors" do %>
52
+ Next
53
+ <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
54
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
55
+ </svg>
56
+ <% end %>
57
+ <% else %>
58
+ <span class="inline-flex items-center gap-1 px-3 py-1.5 text-sm font-medium text-slate-400 bg-slate-100 border border-slate-200 rounded-lg cursor-not-allowed">
59
+ Next
60
+ <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
61
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
62
+ </svg>
63
+ </span>
64
+ <% end %>
65
+ </nav>
66
+ </div>
67
+ </div>
68
+ <% else %>
69
+ <div class="px-6 py-3 border-t border-slate-200">
70
+ <div class="text-sm text-slate-500">
71
+ Showing <span class="font-medium text-slate-700"><%= pagy.count %></span> results
72
+ </div>
73
+ </div>
74
+ <% end %>
@@ -60,7 +60,7 @@
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>
@@ -29,9 +29,11 @@
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 %>
35
37
 
36
38
  <% if respond_to?(:javascript_importmap_tags) %>
37
39
  <%= javascript_importmap_tags %>
@@ -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
@@ -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.5.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,30 @@ module Admin
176
172
  @read_only == true
177
173
  end
178
174
 
175
+ # Deprecated no-op, removed in 0.5.0.
176
+ #
177
+ # `exportable` was write-only in every prior release -- it never had
178
+ # a reader and never drove any export behavior -- but hosts still
179
+ # call it from resource-definition bodies (gleania: 30 files;
180
+ # trust_growth: 1). A real removal would raise `NoMethodError` at
181
+ # definition-load time, and in production `DefinitionLoader` logs
182
+ # and swallows that, so the resource just silently vanishes from the
183
+ # admin. Kept as a no-op instead, so those files keep loading.
184
+ #
185
+ # Deliberately does not restore `@export_formats` or any reader --
186
+ # only the harmless no-op comes back.
187
+ #
188
+ # @param _formats [Array<Symbol>] ignored
189
+ # @return [void]
190
+ def exportable(*_formats)
191
+ # `warn_once`, `warn_once_sink` and `reset_deprecation_notices!`
192
+ # come from `AdminSuite::Deprecation`, extended above. Keyed on the
193
+ # resource class itself, so each resource warns independently (and
194
+ # only once) rather than one call anywhere silencing every other
195
+ # resource's first call.
196
+ warn_once(name, format(EXPORTABLE_DEPRECATION_MESSAGE_FORMAT, resource: name))
197
+ end
198
+
179
199
  # Returns the resource name derived from class name
180
200
  #
181
201
  # @return [String]
@@ -309,7 +329,6 @@ module Admin
309
329
  @columns << ColumnDefinition.new(
310
330
  name: name,
311
331
  content: content,
312
- render: options[:render],
313
332
  header: options[:header] || name.to_s.humanize,
314
333
  css_class: options[:class],
315
334
  type: options[:type],
@@ -321,7 +340,7 @@ module Admin
321
340
  end
322
341
  end
323
342
 
324
- ColumnDefinition = Struct.new(:name, :content, :render, :header, :css_class, :type, :toggle_field, :label_color, :label_size, :sortable, keyword_init: true)
343
+ ColumnDefinition = Struct.new(:name, :content, :header, :css_class, :type, :toggle_field, :label_color, :label_size, :sortable, keyword_init: true)
325
344
 
326
345
  class FiltersBuilder
327
346
  attr_reader :filters
@@ -470,7 +489,25 @@ module Admin
470
489
 
471
490
  private
472
491
 
492
+ # Struct members already given a dedicated home above. Everything
493
+ # else in the panel/section DSL call (`source:`, `empty:`,
494
+ # `language:`, plus `columns:` again — see below) is forwarded
495
+ # verbatim to the renderer via `ShowSectionDefinition#options`.
496
+ RESERVED_SECTION_OPTION_KEYS = %i[
497
+ fields association limit render title display link_to resource
498
+ paginate pagination per_page collapsible collapsed
499
+ ].freeze
500
+
473
501
  def build_section(name, options)
502
+ # `columns:` is deliberately NOT excluded here even though it also
503
+ # populates the dedicated `columns` member below (used today by
504
+ # association-table display). Built-in renderers like
505
+ # `:table_from` only ever see `section.options`, not the section
506
+ # itself, so `columns:` has to reach `options[:columns]` too or a
507
+ # DSL author's explicit column list would be silently dropped in
508
+ # favor of the renderer's own inference.
509
+ leftover_options = options.except(*RESERVED_SECTION_OPTION_KEYS)
510
+
474
511
  ShowSectionDefinition.new(
475
512
  name: name,
476
513
  fields: options[:fields] || [],
@@ -485,7 +522,8 @@ module Admin
485
522
  paginate: options[:paginate] || options[:pagination] || false,
486
523
  per_page: options[:per_page],
487
524
  collapsible: options[:collapsible] || false,
488
- collapsed: options[:collapsed] || false
525
+ collapsed: options[:collapsed] || false,
526
+ options: leftover_options
489
527
  )
490
528
  end
491
529
  end
@@ -493,6 +531,7 @@ module Admin
493
531
  ShowSectionDefinition = Struct.new(
494
532
  :name, :fields, :association, :limit, :render, :title,
495
533
  :display, :columns, :link_to, :resource, :paginate, :per_page, :collapsible, :collapsed,
534
+ :options,
496
535
  keyword_init: true
497
536
  )
498
537
 
@@ -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,16 @@ module AdminSuite
64
78
  @on_action_executed = nil
65
79
  @resolve_action_handler = nil
66
80
  end
81
+
82
+ private
83
+
84
+ # Sets the built-in default portals without marking portals as
85
+ # host-configured. Only the engine's `apply_default_portals!` should
86
+ # call this — it deliberately bypasses the public `portals=` writer so
87
+ # gem-applied defaults stay distinguishable from an explicit host
88
+ # assignment (including an explicit `{}` meant to suppress defaults).
89
+ def default_portals!(value)
90
+ @portals = value
91
+ end
67
92
  end
68
93
  end