admin_suite 0.4.0 → 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 (39) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +134 -7
  3. data/app/assets/vendor/chart.umd.min.js +14 -0
  4. data/app/controllers/admin_suite/resources_controller.rb +130 -5
  5. data/app/helpers/admin_suite/base_helper.rb +187 -10
  6. data/app/javascript/admin_suite_application.js +3 -0
  7. data/app/javascript/controllers/admin_suite/chart_controller.js +173 -0
  8. data/app/views/admin_suite/panels/_chart.html.erb +158 -18
  9. data/app/views/admin_suite/resources/index.html.erb +24 -5
  10. data/app/views/admin_suite/shared/_sidebar.html.erb +14 -8
  11. data/app/views/layouts/admin_suite/application.html.erb +6 -0
  12. data/config/routes.rb +3 -0
  13. data/lib/admin/base/filter_builder.rb +48 -5
  14. data/lib/admin/base/resource.rb +43 -7
  15. data/lib/admin_suite/configuration.rb +10 -6
  16. data/lib/admin_suite/engine.rb +1 -1
  17. data/lib/admin_suite/legacy_custom_renderer_procs.rb +1 -1
  18. data/lib/admin_suite/renderer_registry.rb +11 -0
  19. data/lib/admin_suite/renderers/legacy_gleania.rb +7 -5
  20. data/lib/admin_suite/ui/dashboard_definition.rb +6 -0
  21. data/lib/admin_suite/version.rb +1 -1
  22. data/lib/admin_suite.rb +13 -2
  23. data/test/integration/association_linking_test.rb +292 -0
  24. data/test/integration/chart_panel_test.rb +491 -0
  25. data/test/integration/dashboard_test.rb +9 -2
  26. data/test/integration/index_table_test.rb +289 -0
  27. data/test/integration/navigation_sections_test.rb +21 -0
  28. data/test/integration/searchable_select_search_test.rb +368 -0
  29. data/test/integration/show_hide_blank_test.rb +195 -0
  30. data/test/integration/toggle_test.rb +106 -0
  31. data/test/lib/definition_loader_test.rb +12 -0
  32. data/test/lib/form_field_renderer_test.rb +83 -11
  33. data/test/lib/format_table_cell_test.rb +49 -0
  34. data/test/lib/index_includes_test.rb +223 -0
  35. data/test/lib/legacy_renderer_deprecation_test.rb +8 -1
  36. data/test/lib/renderer_test.rb +23 -7
  37. data/test/lib/resource_exportable_deprecation_test.rb +10 -2
  38. data/test/test_helper.rb +47 -8
  39. metadata +35 -5
@@ -1,7 +1,102 @@
1
- <% data = Array(panel_eval(panel.options[:data])) %>
1
+ <%
2
+ raw_data =
3
+ begin
4
+ Array(panel_eval(panel.options[:data]))
5
+ rescue StandardError => e
6
+ Rails.logger&.warn(
7
+ "AdminSuite: chart panel #{panel.title.inspect} data raised #{e.class}: #{e.message}\n" \
8
+ "#{e.backtrace&.first(5)&.join("\n")}"
9
+ )
10
+ []
11
+ end
12
+ # Accept String-keyed rows (JSONB, API payloads) the same way data_table does
13
+ # (see lib/admin_suite/renderer.rb#data_table), and skip any row that isn't
14
+ # Hash-like: a data proc can *succeed* and still hand back a junk row (e.g.
15
+ # a bare Integer or nil mixed into the array), and indexing that with
16
+ # `row[:value]` raises rather than returning nil, so it must be filtered
17
+ # before it reaches the height math below.
18
+ data = raw_data.filter_map do |row|
19
+ row = row.symbolize_keys if row.respond_to?(:symbolize_keys)
20
+ next unless row.is_a?(Hash)
21
+
22
+ raw_value = row[:value]
23
+ # Total coercion: `Float()` (unlike `#to_f`) raises on Boolean/Hash/Array/
24
+ # nil/non-numeric-String values instead of silently returning 0 or
25
+ # blowing up later, so a junk value degrades to 0 here rather than
26
+ # 500ing the dashboard when the height math below reaches it.
27
+ numeric = begin
28
+ Float(raw_value)
29
+ rescue ArgumentError, TypeError
30
+ 0.0
31
+ end
32
+
33
+ # `value` stays as originally provided (e.g. an Integer or a numeric
34
+ # String) so titles/labels still read "Mon: 3" rather than "Mon: 3.0" —
35
+ # `numeric` is the only thing the height math and the JSON series handed
36
+ # to Chart.js should ever touch.
37
+ { label: row[:label], value: raw_value, numeric: numeric }
38
+ end
39
+ %>
2
40
  <% total = panel.options[:total] %>
3
- <% color = (panel.options[:color] || theme_primary).to_sym %>
4
- <% max_value = (data.map { |d| d[:value].to_f }.max || 1).to_f %>
41
+ <%
42
+ # Total coercion, same idiom as `type:` below: `.to_sym` alone raises for
43
+ # any `color:` value that doesn't implement it (an Integer, Boolean, ...) --
44
+ # `NoMethodError` 500ing the whole dashboard. `.to_s` first makes it total;
45
+ # an unrecognized result (e.g. `color: 42` -> `:"42"`) already falls through
46
+ # to indigo in both the `case` below and the JS `COLOR_HEX` map, so no new
47
+ # fallback logic is needed here.
48
+ color = (panel.options[:color] || theme_primary).to_s.presence&.to_sym
49
+ %>
50
+ <%
51
+ # `type:` is validated here, not just passed through, because it flows to
52
+ # both the Chart.js config (an unrecognized string throws inside Chart.js)
53
+ # and the degraded ERB branch below — a host typo (e.g. `type: :pie`, which
54
+ # Chart.js itself doesn't even ship) must degrade to the bar chart, not
55
+ # break the dashboard or silently render nothing.
56
+ chart_types = %i[bar line area doughnut]
57
+ # `.to_s` first: `presence&.to_sym` alone raises on any non-String/Symbol
58
+ # scalar (an Integer, Float, or `true`/`false` don't define `#to_sym`),
59
+ # which is exactly the host-typo shape this fallback exists to catch.
60
+ # `.to_s` makes the coercion total -- `42 → "42" → :"42"` (fails the
61
+ # allowlist below, warns, falls back to :bar) and `nil → "" → nil` via
62
+ # `presence` (falls back to :bar silently, since an unspecified type is
63
+ # not a typo).
64
+ chart_type = panel.options[:type].to_s.presence&.to_sym
65
+ if chart_type && !chart_types.include?(chart_type)
66
+ Rails.logger&.warn(
67
+ "AdminSuite: chart panel #{panel.title.inspect} has unknown type #{chart_type.inspect}; " \
68
+ "falling back to :bar. Supported: #{chart_types.join(', ')}."
69
+ )
70
+ chart_type = nil
71
+ end
72
+ chart_type ||= :bar
73
+ %>
74
+ <%
75
+ # Chart height: 64px (the old `h-16` bar height) was fine for sparkline-ish
76
+ # CSS bars, but is not usable for a real Chart.js chart with axes/legend.
77
+ # Emitted as an inline style below, not an interpolated Tailwind height
78
+ # class (which the content scanner can't see at build time, since it's
79
+ # never a literal class name in source — see panels_helper.rb's span
80
+ # comment for the same lesson), so both the degraded bars and the upgraded
81
+ # canvas share an identical, explicit height and there's no layout shift
82
+ # when Chart.js takes over.
83
+ # Total coercion, same idiom as `data:`'s per-row `value` above: `presence`
84
+ # filters `nil`/`""`/`false`/`{}`/`[]`, but `true`, Symbols, non-empty
85
+ # Arrays and non-empty Hashes all survive it and none respond to `#to_i` --
86
+ # `NoMethodError` 500ing the whole dashboard. `Integer(Float(...))` raises
87
+ # on exactly those (plus non-numeric Strings), so anything that can't
88
+ # genuinely parse degrades to the default instead. `RangeError` covers its
89
+ # `FloatDomainError` subclass: `Float::INFINITY` and `NAN` parse fine but
90
+ # have no Integer form, and a host can reach them by computing a height
91
+ # from a ratio that divides by zero.
92
+ chart_height = begin
93
+ Integer(Float(panel.options[:height]))
94
+ rescue ArgumentError, TypeError, RangeError
95
+ 192
96
+ end
97
+ chart_height = 192 if chart_height <= 0
98
+ %>
99
+ <% max_value = (data.map { |d| d[:numeric] }.max || 1).to_f %>
5
100
  <% max_value = 1.0 if max_value.zero? %>
6
101
 
7
102
  <% bar_color = case color
@@ -23,23 +118,68 @@ end %>
23
118
  </div>
24
119
 
25
120
  <% if data.any? %>
26
- <div class="flex items-end gap-1 h-16">
27
- <% data.each do |d| %>
28
- <% value = d[:value].to_f %>
29
- <% height_float = (value / max_value) * 100.0 %>
30
- <% height_float = 0.0 if height_float.nan? || height_float.infinite? %>
31
- <% height = height_float.round %>
32
- <% height = [height, 2].max if value.positive? %>
33
- <div class="flex-1 h-full flex flex-col items-center justify-end">
34
- <div class="w-full rounded-t <%= bar_color %> transition-all" style="height: <%= height %>%"
35
- title="<%= "#{d[:label]}: #{d[:value]}" %>"></div>
36
- </div>
121
+ <%
122
+ # Load the vendored Chart.js assets only on pages that actually render a
123
+ # chart with data, via a dedicated content_for hook consumed by the
124
+ # layout (see app/views/layouts/admin_suite/application.html.erb).
125
+ # Mirrors the EasyMDE hook in
126
+ # lib/admin_suite/ui/field_renderer_registry.rb's :markdown handler.
127
+ # Guarded so multiple chart panels on one page don't emit the tags twice.
128
+ %>
129
+ <% unless content_for?(:chart_assets) %>
130
+ <% content_for(:chart_assets) do %>
131
+ <%# "vendor/chart.umd.min" (not bare "chart.umd.min"): Propshaft resolves
132
+ assets by path relative to a load-path root, and app/assets/vendor
133
+ lives *under* the already-registered app/assets root, so its files
134
+ are found at "vendor/chart.umd.min.js". %>
135
+ <%= javascript_include_tag "vendor/chart.umd.min", "data-turbo-track": "reload" %>
37
136
  <% end %>
38
- </div>
137
+ <% end %>
39
138
 
40
- <div class="flex gap-1 mt-2">
41
- <% data.each do |d| %>
42
- <div class="flex-1 text-center text-xs text-slate-400" title="<%= d[:label] %>"><%= d[:label] %></div>
139
+ <%
140
+ # Chart.js gets the coerced numeric series, never the raw display
141
+ # value a Boolean/Hash/Array/nil `value` would otherwise reach
142
+ # Chart.js as-is and break the chart, even though it's already been
143
+ # safely zeroed out for the CSS bars above.
144
+ series_for_chart_js = data.map { |d| { label: d[:label], value: d[:numeric] } }
145
+ %>
146
+ <div data-controller="admin-suite--chart"
147
+ data-admin-suite--chart-series-value="<%= series_for_chart_js.to_json %>"
148
+ data-admin-suite--chart-type-value="<%= chart_type %>"
149
+ data-admin-suite--chart-color-value="<%= color %>"
150
+ data-admin-suite--chart-height-value="<%= chart_height %>">
151
+ <% if chart_type == :doughnut %>
152
+ <%# A stacked bar makes no sense as a doughnut's degraded (no-JS)
153
+ state -- there's no "height" a doughnut slice maps to. A plain
154
+ labelled value list carries the same data legibly instead. %>
155
+ <div class="divide-y divide-slate-100" data-admin-suite--chart-target="list">
156
+ <% data.each do |d| %>
157
+ <div class="flex items-center justify-between py-1.5 text-sm">
158
+ <span class="text-slate-600 truncate" title="<%= d[:label] %>"><%= d[:label] %></span>
159
+ <span class="font-medium text-slate-900"><%= d[:value] %></span>
160
+ </div>
161
+ <% end %>
162
+ </div>
163
+ <% else %>
164
+ <div class="flex items-end gap-1" data-admin-suite--chart-target="bars" style="height: <%= chart_height %>px;">
165
+ <% data.each do |d| %>
166
+ <% value = d[:numeric] %>
167
+ <% height_float = (value / max_value) * 100.0 %>
168
+ <% height_float = 0.0 if height_float.nan? || height_float.infinite? %>
169
+ <% height = height_float.round %>
170
+ <% height = [height, 2].max if value.positive? %>
171
+ <div class="flex-1 h-full flex flex-col items-center justify-end">
172
+ <div class="w-full rounded-t <%= bar_color %> transition-all" style="height: <%= height %>%"
173
+ title="<%= "#{d[:label]}: #{d[:value]}" %>"></div>
174
+ </div>
175
+ <% end %>
176
+ </div>
177
+
178
+ <div class="flex gap-1 mt-2" data-admin-suite--chart-target="labels">
179
+ <% data.each do |d| %>
180
+ <div class="flex-1 text-center text-xs text-slate-400" title="<%= d[:label] %>"><%= d[:label] %></div>
181
+ <% end %>
182
+ </div>
43
183
  <% end %>
44
184
  </div>
45
185
  <% else %>
@@ -125,6 +125,14 @@
125
125
  </div>
126
126
  <% end %>
127
127
 
128
+ <div>
129
+ <label class="block text-sm font-medium text-slate-700 mb-1">Per page</label>
130
+ <%= select_tag :per_page,
131
+ options_for_select([ 25, 50, 100 ], @pagy&.limit),
132
+ class: "form-input w-full",
133
+ data: { "admin-suite--live-filter-target": "input", action: "change->admin-suite--live-filter#submit" } %>
134
+ </div>
135
+
128
136
  <div class="flex gap-2 pt-2">
129
137
  <button type="submit" class="admin-suite-btn-primary flex-1 text-sm font-medium rounded-lg transition-colors">
130
138
  Apply
@@ -143,9 +151,9 @@
143
151
  <%= turbo_frame_tag "resource_results", data: { turbo_action: "advance" } do %>
144
152
  <div class="bg-white rounded-xl border border-slate-200 overflow-hidden">
145
153
  <% if @collection.any? %>
146
- <div class="overflow-x-auto">
154
+ <div class="overflow-x-auto overflow-y-auto max-h-[70vh]">
147
155
  <table class="min-w-full divide-y divide-slate-200">
148
- <thead class="bg-slate-50">
156
+ <thead class="bg-slate-50 sticky top-0 z-10">
149
157
  <tr>
150
158
  <%
151
159
  current_sort = params[:sort]&.to_sym || resource_config.index_config.default_sort
@@ -189,9 +197,20 @@
189
197
  </thead>
190
198
  <tbody class="divide-y divide-slate-200">
191
199
  <% @collection.each do |record| %>
192
- <tr class="hover:bg-slate-50">
200
+ <%
201
+ show_path = url_for(action: :show, id: record.to_param)
202
+ %>
203
+ <%= tag.tr class: "hover:bg-slate-50 cursor-pointer",
204
+ data: {
205
+ controller: "admin-suite--click-actions",
206
+ action: "click->admin-suite--click-actions#navigate",
207
+ "admin-suite--click-actions-url-value": show_path
208
+ } do %>
193
209
  <% resource_config.index_config.columns_list.each do |column| %>
194
- <td class="px-4 py-3 text-sm text-slate-900">
210
+ <%
211
+ td_class = [ "px-4 py-3 text-sm text-slate-900", column_align_class(column.align), column.css_class ].reject(&:blank?).join(" ")
212
+ %>
213
+ <td class="<%= td_class %>">
195
214
  <%= render_column_value(record, column) %>
196
215
  </td>
197
216
  <% end %>
@@ -211,7 +230,7 @@
211
230
  <% end %>
212
231
  </div>
213
232
  </td>
214
- </tr>
233
+ <% end %>
215
234
  <% end %>
216
235
  </tbody>
217
236
  </table>
@@ -64,16 +64,22 @@
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>
@@ -35,6 +35,12 @@
35
35
  this content_for). %>
36
36
  <%= yield :easymde_assets %>
37
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 %>
43
+
38
44
  <% if respond_to?(:javascript_importmap_tags) %>
39
45
  <%= javascript_importmap_tags %>
40
46
  <%= javascript_import_module_tag "admin_suite_application" %>
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
@@ -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
 
@@ -43,7 +43,7 @@ module Admin
43
43
 
44
44
  EXPORTABLE_DEPRECATION_MESSAGE_FORMAT =
45
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 " \
46
+ "no-op and will be removed in 0.6.0. It never actually implemented " \
47
47
  "export in any released version — safe to delete the call."
48
48
 
49
49
  class << self
@@ -172,7 +172,9 @@ module Admin
172
172
  @read_only == true
173
173
  end
174
174
 
175
- # Deprecated no-op, removed in 0.5.0.
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).
176
178
  #
177
179
  # `exportable` was write-only in every prior release -- it never had
178
180
  # a reader and never drove any export behavior -- but hosts still
@@ -272,7 +274,7 @@ module Admin
272
274
  # Index view configuration
273
275
  class IndexConfig
274
276
  attr_reader :searchable_fields, :sortable_fields, :default_sort, :default_sort_direction,
275
- :columns_list, :filters_list, :stats_list, :per_page
277
+ :columns_list, :filters_list, :stats_list, :per_page, :includes_list
276
278
 
277
279
  def initialize
278
280
  @searchable_fields = []
@@ -283,6 +285,7 @@ module Admin
283
285
  @filters_list = []
284
286
  @stats_list = []
285
287
  @per_page = 25
288
+ @includes_list = []
286
289
  end
287
290
 
288
291
  def searchable(*fields)
@@ -299,6 +302,23 @@ module Admin
299
302
  @per_page = count
300
303
  end
301
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
+
302
322
  def columns(&block)
303
323
  builder = ColumnsBuilder.new
304
324
  builder.instance_eval(&block) if block_given?
@@ -335,12 +355,13 @@ module Admin
335
355
  toggle_field: options[:toggle_field],
336
356
  label_color: options[:label_color],
337
357
  label_size: options[:label_size],
338
- sortable: options[:sortable] || false
358
+ sortable: options[:sortable] || false,
359
+ align: options[:align]
339
360
  )
340
361
  end
341
362
  end
342
363
 
343
- ColumnDefinition = Struct.new(:name, :content, :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)
344
365
 
345
366
  class FiltersBuilder
346
367
  attr_reader :filters
@@ -412,7 +433,8 @@ module Admin
412
433
  variants: options[:variants],
413
434
  label_color: options[:label_color],
414
435
  label_size: options[:label_size],
415
- parent_field: options[:parent_field]
436
+ parent_field: options[:parent_field],
437
+ resource: options[:resource]
416
438
  )
417
439
  end
418
440
 
@@ -439,6 +461,12 @@ module Admin
439
461
  :collection, :create_url, :accept, :rows, :readonly,
440
462
  :if_condition, :unless_condition, :multiple, :creatable,
441
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,
442
470
  keyword_init: true
443
471
  )
444
472
 
@@ -495,7 +523,7 @@ module Admin
495
523
  # verbatim to the renderer via `ShowSectionDefinition#options`.
496
524
  RESERVED_SECTION_OPTION_KEYS = %i[
497
525
  fields association limit render title display link_to resource
498
- paginate pagination per_page collapsible collapsed
526
+ paginate pagination per_page collapsible collapsed hide_blank
499
527
  ].freeze
500
528
 
501
529
  def build_section(name, options)
@@ -523,6 +551,13 @@ module Admin
523
551
  per_page: options[:per_page],
524
552
  collapsible: options[:collapsible] || false,
525
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,
526
561
  options: leftover_options
527
562
  )
528
563
  end
@@ -531,6 +566,7 @@ module Admin
531
566
  ShowSectionDefinition = Struct.new(
532
567
  :name, :fields, :association, :limit, :render, :title,
533
568
  :display, :columns, :link_to, :resource, :paginate, :per_page, :collapsible, :collapsed,
569
+ :hide_blank,
534
570
  :options,
535
571
  keyword_init: true
536
572
  )
@@ -79,13 +79,17 @@ module AdminSuite
79
79
  @resolve_action_handler = nil
80
80
  end
81
81
 
82
- private
83
-
84
82
  # 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).
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).
89
93
  def default_portals!(value)
90
94
  @portals = value
91
95
  end
@@ -130,7 +130,7 @@ module AdminSuite
130
130
  def self.apply_default_portals!(config)
131
131
  return if config.portals_configured? || config.portals.present?
132
132
 
133
- config.send(:default_portals!, {
133
+ config.default_portals!({
134
134
  ops: { label: "Ops Portal", icon: "settings", color: :amber, order: 10 },
135
135
  email: { label: "Email Portal", icon: "inbox", color: :emerald, order: 20 },
136
136
  ai: { label: "AI Portal", icon: "cpu", color: :cyan, order: 30 },
@@ -12,7 +12,7 @@ module AdminSuite
12
12
 
13
13
  DEPRECATION_MESSAGE_FORMAT =
14
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."
15
+ "and will be removed in 0.6.0. Migrate to an AdminSuite::Renderer subclass."
16
16
 
17
17
  class << self
18
18
  # Fires the deprecation sink at most once per `key` per process.
@@ -65,6 +65,17 @@ module AdminSuite
65
65
  def unregister(key)
66
66
  @registry.delete(key.to_sym)
67
67
  end
68
+
69
+ # Test-support only. Removes a single *default* registration.
70
+ #
71
+ # Exists solely so a spec that seeds a scratch key into the default
72
+ # store (e.g. to test host-class-vs-default precedence) can clean up
73
+ # after itself. AdminSuite's own built-in defaults are registered once
74
+ # at require time and are never expected to be removed in a running
75
+ # process -- do not call this outside a test teardown.
76
+ def unregister_default(key)
77
+ @defaults.delete(key.to_sym)
78
+ end
68
79
  end
69
80
  end
70
81
  end
@@ -7,9 +7,11 @@ module AdminSuite
7
7
  # panels, prompt-template mustache highlighting).
8
8
  #
9
9
  # These are deprecated: they still work in 0.4.0 (logging a warning once
10
- # per key per process the first time each is used), and are DELETED in
11
- # 0.5.0 (Phase 2b). Gleania is expected to migrate to host-side renderer
12
- # classes under `app/admin/renderers/*.rb` before then.
10
+ # per key per process the first time each is used), and are targeted for
11
+ # DELETION in 0.6.0 (moved from the originally-planned 0.5.0/Phase 2b,
12
+ # pending both the gleania and trust_growth host migrations). Gleania is
13
+ # expected to migrate to host-side renderer classes under
14
+ # `app/admin/renderers/*.rb` before then.
13
15
  #
14
16
  # The renderer bodies below are intentionally byte-equivalent to the
15
17
  # BaseHelper methods they replace, other than `resource` -> `record` and
@@ -18,12 +20,12 @@ module AdminSuite
18
20
  # so those calls can't resolve as bare method sends the way they did
19
21
  # inside the helper module. Do not "improve" this markup: a byte-for-byte
20
22
  # move keeps gleania's Assistant/AI portal pages pixel-identical, and
21
- # makes the 0.5.0 deletion a clean removal.
23
+ # makes the eventual 0.6.0 deletion a clean removal.
22
24
  module LegacyGleania
23
25
  extend AdminSuite::Deprecation
24
26
 
25
27
  DEPRECATION_MESSAGE_FORMAT =
26
- "AdminSuite: the :%<key>s renderer is deprecated and will be removed in 0.5.0. " \
28
+ "AdminSuite: the :%<key>s renderer is deprecated and will be removed in 0.6.0. " \
27
29
  "Move it to app/admin/renderers in your app."
28
30
 
29
31
  class << self
@@ -48,6 +48,12 @@ module AdminSuite
48
48
  panel(:health, title, span: span, **options.merge(status: status, metrics: metrics, block: block))
49
49
  end
50
50
 
51
+ # `type:` selects the Chart.js chart type: `:bar` (default), `:line`,
52
+ # `:area` (a line chart rendered with fill), or `:doughnut`. It flows
53
+ # through `**options` untouched by this method -- the `_chart.html.erb`
54
+ # partial is what validates it, falling back to `:bar` with a logged
55
+ # warning for anything else, so a host typo degrades rather than
56
+ # breaking the dashboard.
51
57
  def chart_panel(title, data: nil, span: nil, **options, &block)
52
58
  data_proc = data.is_a?(Proc) ? data : (block_given? ? block : nil)
53
59
  panel(:chart, title, span: span, **options.merge(data: data_proc || data))
@@ -2,7 +2,7 @@
2
2
 
3
3
  module AdminSuite
4
4
  module Version
5
- VERSION = "0.4.0"
5
+ VERSION = "0.5.0"
6
6
  end
7
7
 
8
8
  # Backward-compatible constant.