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.
- checksums.yaml +4 -4
- data/.gitignore +7 -0
- data/CHANGELOG.md +203 -18
- data/CONTRIBUTING.md +9 -4
- data/README.md +21 -5
- data/app/assets/vendor/chart.umd.min.js +14 -0
- data/app/controllers/admin_suite/application_controller.rb +12 -11
- data/app/controllers/admin_suite/mcp_controller.rb +36 -0
- data/app/controllers/admin_suite/resources_controller.rb +71 -17
- data/app/helpers/admin_suite/base_helper.rb +187 -10
- data/app/javascript/admin_suite_application.js +3 -0
- data/app/javascript/controllers/admin_suite/chart_controller.js +173 -0
- data/app/views/admin_suite/panels/_chart.html.erb +158 -18
- data/app/views/admin_suite/resources/index.html.erb +25 -5
- data/app/views/admin_suite/shared/_sidebar.html.erb +14 -8
- data/app/views/layouts/admin_suite/application.html.erb +6 -0
- data/config/routes.rb +8 -0
- data/lib/admin/base/filter_builder.rb +48 -5
- data/lib/admin/base/resource.rb +46 -32
- data/lib/admin_suite/auth/host_user.rb +42 -0
- data/lib/admin_suite/auth/strategy.rb +1 -1
- data/lib/admin_suite/auth.rb +15 -0
- data/lib/admin_suite/authorization_context.rb +28 -0
- data/lib/admin_suite/configuration.rb +66 -8
- data/lib/admin_suite/engine.rb +1 -1
- data/lib/admin_suite/legacy_custom_renderer_procs.rb +1 -1
- data/lib/admin_suite/mcp/authorization.rb +68 -0
- data/lib/admin_suite/mcp/serializer.rb +138 -0
- data/lib/admin_suite/mcp/tools/aggregate.rb +55 -0
- data/lib/admin_suite/mcp/tools/describe_resources.rb +63 -0
- data/lib/admin_suite/mcp/tools/get_record.rb +41 -0
- data/lib/admin_suite/mcp/tools/list_records.rb +67 -0
- data/lib/admin_suite/mcp.rb +53 -0
- data/lib/admin_suite/query.rb +71 -0
- data/lib/admin_suite/renderer_registry.rb +11 -0
- data/lib/admin_suite/ui/dashboard_definition.rb +6 -0
- data/lib/admin_suite/ui/show_value_formatter.rb +2 -2
- data/lib/admin_suite/version.rb +1 -1
- data/lib/admin_suite.rb +23 -7
- data/lib/generators/admin_suite/install/templates/admin_suite.rb +5 -2
- data/test/controllers/resources_controller_test.rb +19 -13
- data/test/integration/association_linking_test.rb +292 -0
- data/test/integration/authentication_test.rb +10 -0
- data/test/integration/authorization_test.rb +20 -3
- data/test/integration/chart_panel_test.rb +491 -0
- data/test/integration/dashboard_test.rb +9 -2
- data/test/integration/index_query_characterization_test.rb +229 -0
- data/test/integration/index_table_test.rb +289 -0
- data/test/integration/mcp_aggregate_test.rb +44 -0
- data/test/integration/mcp_authorization_test.rb +102 -0
- data/test/integration/mcp_endpoint_test.rb +89 -0
- data/test/integration/mcp_get_record_test.rb +62 -0
- data/test/integration/mcp_instrumentation_test.rb +107 -0
- data/test/integration/mcp_list_records_test.rb +75 -0
- data/test/integration/mcp_parity_test.rb +155 -0
- data/test/integration/mcp_release_test.rb +131 -0
- data/test/integration/navigation_sections_test.rb +21 -0
- data/test/integration/read_only_resource_test.rb +128 -2
- data/test/integration/searchable_select_search_test.rb +369 -0
- data/test/integration/show_hide_blank_test.rb +195 -0
- data/test/integration/toggle_test.rb +106 -0
- data/test/lib/auth_host_user_test.rb +52 -0
- data/test/lib/auth_http_basic_test.rb +10 -0
- data/test/lib/auth_test.rb +20 -3
- data/test/lib/authorization_context_test.rb +127 -0
- data/test/lib/definition_loader_test.rb +12 -0
- data/test/lib/engine_defaults_test.rb +1 -2
- data/test/lib/form_field_renderer_test.rb +83 -11
- data/test/lib/format_table_cell_test.rb +49 -0
- data/test/lib/index_includes_test.rb +223 -0
- data/test/lib/mcp_serializer_test.rb +107 -0
- data/test/lib/query_test.rb +91 -0
- data/test/lib/removed_deprecations_test.rb +16 -0
- data/test/lib/renderer_test.rb +23 -7
- data/test/publish_workflow_test.rb +63 -0
- data/test/test_helper.rb +73 -8
- metadata +76 -9
- data/lib/admin_suite/renderers/legacy_gleania.rb +0 -230
- data/test/lib/legacy_renderer_deprecation_test.rb +0 -35
- data/test/lib/resource_exportable_deprecation_test.rb +0 -39
|
@@ -1,7 +1,102 @@
|
|
|
1
|
-
<%
|
|
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
|
-
<%
|
|
4
|
-
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
137
|
+
<% end %>
|
|
39
138
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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].
|
|
68
|
-
<%
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
42
|
-
|
|
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
|
|
data/lib/admin/base/resource.rb
CHANGED
|
@@ -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
|
-
#
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
data/lib/admin_suite/auth.rb
CHANGED
|
@@ -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
|