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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +134 -7
- data/app/assets/vendor/chart.umd.min.js +14 -0
- data/app/controllers/admin_suite/resources_controller.rb +130 -5
- 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 +24 -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 +3 -0
- data/lib/admin/base/filter_builder.rb +48 -5
- data/lib/admin/base/resource.rb +43 -7
- data/lib/admin_suite/configuration.rb +10 -6
- data/lib/admin_suite/engine.rb +1 -1
- data/lib/admin_suite/legacy_custom_renderer_procs.rb +1 -1
- data/lib/admin_suite/renderer_registry.rb +11 -0
- data/lib/admin_suite/renderers/legacy_gleania.rb +7 -5
- data/lib/admin_suite/ui/dashboard_definition.rb +6 -0
- data/lib/admin_suite/version.rb +1 -1
- data/lib/admin_suite.rb +13 -2
- data/test/integration/association_linking_test.rb +292 -0
- data/test/integration/chart_panel_test.rb +491 -0
- data/test/integration/dashboard_test.rb +9 -2
- data/test/integration/index_table_test.rb +289 -0
- data/test/integration/navigation_sections_test.rb +21 -0
- data/test/integration/searchable_select_search_test.rb +368 -0
- data/test/integration/show_hide_blank_test.rb +195 -0
- data/test/integration/toggle_test.rb +106 -0
- data/test/lib/definition_loader_test.rb +12 -0
- 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/legacy_renderer_deprecation_test.rb +8 -1
- data/test/lib/renderer_test.rb +23 -7
- data/test/lib/resource_exportable_deprecation_test.rb +10 -2
- data/test/test_helper.rb +47 -8
- metadata +35 -5
|
@@ -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,20 @@
|
|
|
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
|
+
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
|
-
|
|
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
|
-
|
|
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].
|
|
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
|
@@ -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
|
-
|
|
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
|
@@ -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.
|
|
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
|
|
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.
|
|
86
|
-
#
|
|
87
|
-
#
|
|
88
|
-
#
|
|
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
|
data/lib/admin_suite/engine.rb
CHANGED
|
@@ -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.
|
|
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.
|
|
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
|
|
11
|
-
# 0.
|
|
12
|
-
#
|
|
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.
|
|
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.
|
|
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))
|