admin_suite 0.4.0 → 0.6.1

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 (80) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +7 -0
  3. data/CHANGELOG.md +203 -18
  4. data/CONTRIBUTING.md +9 -4
  5. data/README.md +21 -5
  6. data/app/assets/vendor/chart.umd.min.js +14 -0
  7. data/app/controllers/admin_suite/application_controller.rb +12 -11
  8. data/app/controllers/admin_suite/mcp_controller.rb +36 -0
  9. data/app/controllers/admin_suite/resources_controller.rb +71 -17
  10. data/app/helpers/admin_suite/base_helper.rb +187 -10
  11. data/app/javascript/admin_suite_application.js +3 -0
  12. data/app/javascript/controllers/admin_suite/chart_controller.js +173 -0
  13. data/app/views/admin_suite/panels/_chart.html.erb +158 -18
  14. data/app/views/admin_suite/resources/index.html.erb +25 -5
  15. data/app/views/admin_suite/shared/_sidebar.html.erb +14 -8
  16. data/app/views/layouts/admin_suite/application.html.erb +6 -0
  17. data/config/routes.rb +8 -0
  18. data/lib/admin/base/filter_builder.rb +48 -5
  19. data/lib/admin/base/resource.rb +46 -32
  20. data/lib/admin_suite/auth/host_user.rb +42 -0
  21. data/lib/admin_suite/auth/strategy.rb +1 -1
  22. data/lib/admin_suite/auth.rb +15 -0
  23. data/lib/admin_suite/authorization_context.rb +28 -0
  24. data/lib/admin_suite/configuration.rb +66 -8
  25. data/lib/admin_suite/engine.rb +1 -1
  26. data/lib/admin_suite/legacy_custom_renderer_procs.rb +1 -1
  27. data/lib/admin_suite/mcp/authorization.rb +68 -0
  28. data/lib/admin_suite/mcp/serializer.rb +138 -0
  29. data/lib/admin_suite/mcp/tools/aggregate.rb +55 -0
  30. data/lib/admin_suite/mcp/tools/describe_resources.rb +63 -0
  31. data/lib/admin_suite/mcp/tools/get_record.rb +41 -0
  32. data/lib/admin_suite/mcp/tools/list_records.rb +67 -0
  33. data/lib/admin_suite/mcp.rb +53 -0
  34. data/lib/admin_suite/query.rb +71 -0
  35. data/lib/admin_suite/renderer_registry.rb +11 -0
  36. data/lib/admin_suite/ui/dashboard_definition.rb +6 -0
  37. data/lib/admin_suite/ui/show_value_formatter.rb +2 -2
  38. data/lib/admin_suite/version.rb +1 -1
  39. data/lib/admin_suite.rb +23 -7
  40. data/lib/generators/admin_suite/install/templates/admin_suite.rb +5 -2
  41. data/test/controllers/resources_controller_test.rb +19 -13
  42. data/test/integration/association_linking_test.rb +292 -0
  43. data/test/integration/authentication_test.rb +10 -0
  44. data/test/integration/authorization_test.rb +20 -3
  45. data/test/integration/chart_panel_test.rb +491 -0
  46. data/test/integration/dashboard_test.rb +9 -2
  47. data/test/integration/index_query_characterization_test.rb +229 -0
  48. data/test/integration/index_table_test.rb +289 -0
  49. data/test/integration/mcp_aggregate_test.rb +44 -0
  50. data/test/integration/mcp_authorization_test.rb +102 -0
  51. data/test/integration/mcp_endpoint_test.rb +89 -0
  52. data/test/integration/mcp_get_record_test.rb +62 -0
  53. data/test/integration/mcp_instrumentation_test.rb +107 -0
  54. data/test/integration/mcp_list_records_test.rb +75 -0
  55. data/test/integration/mcp_parity_test.rb +155 -0
  56. data/test/integration/mcp_release_test.rb +131 -0
  57. data/test/integration/navigation_sections_test.rb +21 -0
  58. data/test/integration/read_only_resource_test.rb +128 -2
  59. data/test/integration/searchable_select_search_test.rb +369 -0
  60. data/test/integration/show_hide_blank_test.rb +195 -0
  61. data/test/integration/toggle_test.rb +106 -0
  62. data/test/lib/auth_host_user_test.rb +52 -0
  63. data/test/lib/auth_http_basic_test.rb +10 -0
  64. data/test/lib/auth_test.rb +20 -3
  65. data/test/lib/authorization_context_test.rb +127 -0
  66. data/test/lib/definition_loader_test.rb +12 -0
  67. data/test/lib/engine_defaults_test.rb +1 -2
  68. data/test/lib/form_field_renderer_test.rb +83 -11
  69. data/test/lib/format_table_cell_test.rb +49 -0
  70. data/test/lib/index_includes_test.rb +223 -0
  71. data/test/lib/mcp_serializer_test.rb +107 -0
  72. data/test/lib/query_test.rb +91 -0
  73. data/test/lib/removed_deprecations_test.rb +16 -0
  74. data/test/lib/renderer_test.rb +23 -7
  75. data/test/publish_workflow_test.rb +63 -0
  76. data/test/test_helper.rb +73 -8
  77. metadata +76 -9
  78. data/lib/admin_suite/renderers/legacy_gleania.rb +0 -230
  79. data/test/lib/legacy_renderer_deprecation_test.rb +0 -35
  80. data/test/lib/resource_exportable_deprecation_test.rb +0 -39
@@ -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,21 @@
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
+ record_id: record.to_param,
206
+ controller: "admin-suite--click-actions",
207
+ action: "click->admin-suite--click-actions#navigate",
208
+ "admin-suite--click-actions-url-value": show_path
209
+ } do %>
193
210
  <% resource_config.index_config.columns_list.each do |column| %>
194
- <td class="px-4 py-3 text-sm text-slate-900">
211
+ <%
212
+ td_class = [ "px-4 py-3 text-sm text-slate-900", column_align_class(column.align), column.css_class ].reject(&:blank?).join(" ")
213
+ %>
214
+ <td class="<%= td_class %>">
195
215
  <%= render_column_value(record, column) %>
196
216
  </td>
197
217
  <% end %>
@@ -211,7 +231,7 @@
211
231
  <% end %>
212
232
  </div>
213
233
  </td>
214
- </tr>
234
+ <% end %>
215
235
  <% end %>
216
236
  </tbody>
217
237
  </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
@@ -7,6 +7,11 @@ AdminSuite::Engine.routes.draw do
7
7
  get "docs(/)", to: "docs#index", as: :docs
8
8
  get "docs/*path", to: "docs#show", as: :doc, format: false
9
9
 
10
+ # Must precede the portal catch-all, which would otherwise treat "mcp" as
11
+ # a portal name.
12
+ post "mcp", to: "mcp#create", as: :mcp
13
+ match "mcp", to: "mcp#create", via: [:get, :delete]
14
+
10
15
  # Portal dashboards (e.g. /ops, /email). Accept optional trailing slash.
11
16
  get ":portal(/)", to: "portals#show", as: :portal
12
17
 
@@ -14,6 +19,9 @@ AdminSuite::Engine.routes.draw do
14
19
  scope ":portal/:resource_name" do
15
20
  get "/", to: "resources#index", as: :resources
16
21
  get "/new", to: "resources#new", as: :new_resource
22
+ # Must precede "/:id" -- a literal segment match wins over the dynamic
23
+ # one only because Rails tries routes in declaration order.
24
+ get "/search", to: "resources#search", as: :search_resources
17
25
  post "/", to: "resources#create"
18
26
  get "/:id", to: "resources#show", as: :resource
19
27
  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
 
@@ -41,11 +41,6 @@ module Admin
41
41
  class Resource
42
42
  extend AdminSuite::Deprecation
43
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
-
49
44
  class << self
50
45
  # Model configuration
51
46
  attr_reader :model_class, :portal_name, :section_name, :nav_label, :nav_icon, :nav_order
@@ -172,28 +167,13 @@ module Admin
172
167
  @read_only == true
173
168
  end
174
169
 
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))
170
+ # Opts this resource out of the model-facing MCP surface.
171
+ def mcp(enabled)
172
+ @mcp_enabled = enabled
173
+ end
174
+
175
+ def mcp_enabled?
176
+ @mcp_enabled.nil? ? true : !!@mcp_enabled
197
177
  end
198
178
 
199
179
  # Returns the resource name derived from class name
@@ -272,7 +252,7 @@ module Admin
272
252
  # Index view configuration
273
253
  class IndexConfig
274
254
  attr_reader :searchable_fields, :sortable_fields, :default_sort, :default_sort_direction,
275
- :columns_list, :filters_list, :stats_list, :per_page
255
+ :columns_list, :filters_list, :stats_list, :per_page, :includes_list
276
256
 
277
257
  def initialize
278
258
  @searchable_fields = []
@@ -283,6 +263,7 @@ module Admin
283
263
  @filters_list = []
284
264
  @stats_list = []
285
265
  @per_page = 25
266
+ @includes_list = []
286
267
  end
287
268
 
288
269
  def searchable(*fields)
@@ -299,6 +280,23 @@ module Admin
299
280
  @per_page = count
300
281
  end
301
282
 
283
+ # Kills the index's association N+1 (Task 3 made `belongs_to`
284
+ # columns render as links, so each one now genuinely dereferences
285
+ # the association -- one query per row without this). The
286
+ # controller applies this to the filtered scope only when the
287
+ # scope actually responds to `#includes` -- PORO test doubles and
288
+ # some host scopes don't -- and swallows a bad association name
289
+ # (typo, renamed association) by logging and rendering
290
+ # unoptimized rather than 500ing. `nil` entries (e.g. a stray
291
+ # `includes nil`) are dropped rather than stored and handed to the
292
+ # scope verbatim.
293
+ #
294
+ # @param associations [Array<Symbol, String>]
295
+ # @return [void]
296
+ def includes(*associations)
297
+ @includes_list = associations.flatten.compact
298
+ end
299
+
302
300
  def columns(&block)
303
301
  builder = ColumnsBuilder.new
304
302
  builder.instance_eval(&block) if block_given?
@@ -335,12 +333,13 @@ module Admin
335
333
  toggle_field: options[:toggle_field],
336
334
  label_color: options[:label_color],
337
335
  label_size: options[:label_size],
338
- sortable: options[:sortable] || false
336
+ sortable: options[:sortable] || false,
337
+ align: options[:align]
339
338
  )
340
339
  end
341
340
  end
342
341
 
343
- ColumnDefinition = Struct.new(:name, :content, :header, :css_class, :type, :toggle_field, :label_color, :label_size, :sortable, keyword_init: true)
342
+ ColumnDefinition = Struct.new(:name, :content, :header, :css_class, :type, :toggle_field, :label_color, :label_size, :sortable, :align, keyword_init: true)
344
343
 
345
344
  class FiltersBuilder
346
345
  attr_reader :filters
@@ -412,7 +411,8 @@ module Admin
412
411
  variants: options[:variants],
413
412
  label_color: options[:label_color],
414
413
  label_size: options[:label_size],
415
- parent_field: options[:parent_field]
414
+ parent_field: options[:parent_field],
415
+ resource: options[:resource]
416
416
  )
417
417
  end
418
418
 
@@ -439,6 +439,12 @@ module Admin
439
439
  :collection, :create_url, :accept, :rows, :readonly,
440
440
  :if_condition, :unless_condition, :multiple, :creatable,
441
441
  :preview, :variants, :label_color, :label_size, :parent_field,
442
+ # `resource:` (e.g. `resource: :companies`) lets a `searchable_select`
443
+ # field resolve its search URL automatically against the gem's own
444
+ # search endpoint, rather than requiring every host to supply a
445
+ # `collection:` String pointing at a hand-rolled route. See
446
+ # `render_searchable_select` in `app/helpers/admin_suite/base_helper.rb`.
447
+ :resource,
442
448
  keyword_init: true
443
449
  )
444
450
 
@@ -495,7 +501,7 @@ module Admin
495
501
  # verbatim to the renderer via `ShowSectionDefinition#options`.
496
502
  RESERVED_SECTION_OPTION_KEYS = %i[
497
503
  fields association limit render title display link_to resource
498
- paginate pagination per_page collapsible collapsed
504
+ paginate pagination per_page collapsible collapsed hide_blank
499
505
  ].freeze
500
506
 
501
507
  def build_section(name, options)
@@ -523,6 +529,13 @@ module Admin
523
529
  per_page: options[:per_page],
524
530
  collapsible: options[:collapsible] || false,
525
531
  collapsed: options[:collapsed] || false,
532
+ # `fields:`-only rows whose value is blank (nil or `.empty?`)
533
+ # are omitted entirely -- see `show_value_blank?` in
534
+ # `AdminSuite::BaseHelper`, which reads this member. Default
535
+ # `false`: no existing show page changes unless it opts in.
536
+ # Deliberately NOT `value.blank?` -- that's true for `false`
537
+ # and `0`, both of which are meaningful, rendered values today.
538
+ hide_blank: options[:hide_blank] || false,
526
539
  options: leftover_options
527
540
  )
528
541
  end
@@ -531,6 +544,7 @@ module Admin
531
544
  ShowSectionDefinition = Struct.new(
532
545
  :name, :fields, :association, :limit, :render, :title,
533
546
  :display, :columns, :link_to, :resource, :paginate, :per_page, :collapsible, :collapsed,
547
+ :hide_blank,
534
548
  :options,
535
549
  keyword_init: true
536
550
  )
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AdminSuite
4
+ module Auth
5
+ # Authenticates against the host application's own user.
6
+ #
7
+ # This is the standard strategy and the one the docs lead with: most
8
+ # adopters already have a users table with a role, or a separate
9
+ # admin/developer user type. HTTP Basic and any future mechanism are
10
+ # additional.
11
+ #
12
+ # config.auth_strategy = :host_user
13
+ # config.auth_options = { resolve: ->(controller) { controller.current_user } }
14
+ #
15
+ # The resolver returns the host's user object, or nil to deny. Whatever
16
+ # it returns is passed through `Auth.normalize_actor`, so `config.authorize`
17
+ # always receives a real object or nil -- never a bare `true`.
18
+ class HostUser < Strategy
19
+ def authenticate!(controller)
20
+ resolver = options[:resolve]
21
+
22
+ unless resolver.respond_to?(:call)
23
+ Rails.logger&.error(
24
+ "AdminSuite: auth_strategy :host_user requires config.auth_options[:resolve] " \
25
+ "(a callable taking the controller). Denying every request until it is set."
26
+ )
27
+ return nil
28
+ end
29
+
30
+ actor =
31
+ begin
32
+ resolver.call(controller)
33
+ rescue StandardError => e
34
+ Rails.logger&.warn("AdminSuite: :host_user resolver raised #{e.class}: #{e.message}; denying.")
35
+ nil
36
+ end
37
+
38
+ Auth.normalize_actor(actor)
39
+ end
40
+ end
41
+ end
42
+ end
@@ -12,7 +12,7 @@ module AdminSuite
12
12
  attr_reader :options
13
13
 
14
14
  def initialize(options = {})
15
- @options = options
15
+ @options = options.symbolize_keys
16
16
  end
17
17
 
18
18
  # @param controller [ActionController::Base]
@@ -24,10 +24,25 @@ module AdminSuite
24
24
  def registered
25
25
  @registry.keys
26
26
  end
27
+
28
+ # One definition of "is this a usable actor?", shared by every surface.
29
+ #
30
+ # Legacy `HostHook` returns `true` to mean "authenticated, but I could
31
+ # not name anybody". That is an answer to a different question than
32
+ # authorization asks, so it is not an actor. Lives here rather than in
33
+ # a controller so the MCP surface -- which has no controller -- cannot
34
+ # drift from the web UI's interpretation.
35
+ def normalize_actor(value)
36
+ return nil if value.nil? || value.equal?(true) || value.equal?(false)
37
+
38
+ value
39
+ end
27
40
  end
28
41
  end
29
42
  end
30
43
 
31
44
  require "admin_suite/auth/http_basic"
32
45
  require "admin_suite/auth/host_hook"
46
+ require "admin_suite/auth/host_user"
33
47
  AdminSuite::Auth.register(:http_basic, AdminSuite::Auth::HttpBasic)
48
+ AdminSuite::Auth.register(:host_user, AdminSuite::Auth::HostUser)
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AdminSuite
4
+ # Surface-agnostic context handed to `config.authorize`.
5
+ #
6
+ # Replaces the old `controller:` keyword, which could not be honestly
7
+ # populated for a non-HTTP surface. `surface` lets one hook express
8
+ # different policy per entry point -- e.g. reads from anywhere, writes
9
+ # only from the human UI.
10
+ class AuthorizationContext
11
+ SURFACES = %i[web mcp].freeze
12
+
13
+ attr_reader :surface, :controller, :request
14
+
15
+ def initialize(surface:, controller: nil, request: nil)
16
+ unless SURFACES.include?(surface)
17
+ raise ArgumentError, "Unknown AdminSuite authorization surface #{surface.inspect}. Expected one of #{SURFACES.inspect}."
18
+ end
19
+
20
+ @surface = surface
21
+ @controller = controller
22
+ @request = request
23
+ end
24
+
25
+ def web? = surface == :web
26
+ def mcp? = surface == :mcp
27
+ end
28
+ end