admin_suite 0.3.1 → 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 +210 -1
- data/app/assets/vendor/chart.umd.min.js +14 -0
- data/app/assets/vendor/easymde.min.css +7 -0
- data/app/assets/vendor/easymde.min.js +7 -0
- data/app/controllers/admin_suite/application_controller.rb +26 -47
- data/app/controllers/admin_suite/portals_controller.rb +2 -2
- data/app/controllers/admin_suite/resources_controller.rb +138 -6
- data/app/helpers/admin_suite/base_helper.rb +232 -391
- data/app/javascript/admin_suite_application.js +3 -0
- data/app/javascript/controllers/admin_suite/chart_controller.js +173 -0
- data/app/javascript/controllers/admin_suite/markdown_editor_controller.js +21 -2
- data/app/views/admin_suite/panels/_chart.html.erb +158 -18
- data/app/views/admin_suite/panels/_stat.html.erb +10 -0
- data/app/views/admin_suite/resources/index.html.erb +31 -82
- data/app/views/admin_suite/shared/_pagination.html.erb +74 -0
- data/app/views/admin_suite/shared/_sidebar.html.erb +15 -9
- data/app/views/layouts/admin_suite/application.html.erb +11 -3
- data/config/routes.rb +3 -0
- data/lib/admin/base/action_executor.rb +19 -51
- data/lib/admin/base/filter_builder.rb +48 -5
- data/lib/admin/base/resource.rb +92 -17
- data/lib/admin_suite/configuration.rb +32 -3
- data/lib/admin_suite/definition_loader.rb +194 -0
- data/lib/admin_suite/deprecation.rb +48 -0
- data/lib/admin_suite/engine.rb +55 -76
- data/lib/admin_suite/host_autoload_policy.rb +132 -0
- data/lib/admin_suite/legacy_custom_renderer_procs.rb +29 -0
- data/lib/admin_suite/portal_definition.rb +11 -0
- data/lib/admin_suite/renderer.rb +133 -0
- data/lib/admin_suite/renderer_registry.rb +81 -0
- data/lib/admin_suite/renderers/code_renderer.rb +15 -0
- data/lib/admin_suite/renderers/json_renderer.rb +15 -0
- data/lib/admin_suite/renderers/key_value_renderer.rb +39 -0
- data/lib/admin_suite/renderers/legacy_gleania.rb +232 -0
- data/lib/admin_suite/renderers/table_from_renderer.rb +22 -0
- data/lib/admin_suite/section_definition.rb +42 -0
- data/lib/admin_suite/ui/dashboard_definition.rb +6 -0
- data/lib/admin_suite/ui/field_renderer_registry.rb +31 -4
- data/lib/admin_suite/ui/form_field_renderer.rb +1 -7
- data/lib/admin_suite/ui/show_formatter_registry.rb +9 -0
- data/lib/admin_suite/ui/show_value_formatter.rb +7 -3
- data/lib/admin_suite/version.rb +1 -1
- data/lib/admin_suite.rb +48 -0
- data/lib/generators/admin_suite/install/templates/admin_suite.rb +0 -4
- data/test/controllers/resources_controller_test.rb +76 -1
- 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/layout_assets_test.rb +112 -0
- data/test/integration/navigation_sections_test.rb +62 -0
- data/test/integration/pagination_and_stats_test.rb +152 -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/action_executor_redirect_test.rb +41 -0
- data/test/lib/builtin_renderers_test.rb +202 -0
- data/test/lib/definition_loader_test.rb +276 -0
- data/test/lib/engine_defaults_test.rb +39 -0
- data/test/lib/form_field_renderer_test.rb +136 -0
- 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 +42 -0
- data/test/lib/renderer_test.rb +237 -0
- data/test/lib/resource_exportable_deprecation_test.rb +47 -0
- data/test/lib/show_value_formatter_test.rb +88 -0
- data/test/lib/zeitwerk_integration_test.rb +28 -64
- data/test/test_helper.rb +121 -5
- metadata +62 -5
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "test_helper"
|
|
4
|
+
|
|
5
|
+
# Self-contained fixtures, following the pattern established in
|
|
6
|
+
# association_linking_test.rb/pagination_and_stats_test.rb: this file must
|
|
7
|
+
# work in isolation under `rake test TEST=test/integration/index_table_test.rb`,
|
|
8
|
+
# so it defines everything it needs rather than depending on fixtures that
|
|
9
|
+
# live only in a sibling test file. `LinkingFixtures::Company` is the one
|
|
10
|
+
# exception -- it lives in test_helper.rb (loaded for every run) precisely
|
|
11
|
+
# so tasks like this one can reuse it (see test_helper.rb's comment above
|
|
12
|
+
# it).
|
|
13
|
+
module IndexTableFixtures
|
|
14
|
+
# Two rows: one fully populated (including a present `belongs_to`-shaped
|
|
15
|
+
# association), one deliberately sparse (nil scalar, nil association) --
|
|
16
|
+
# covers item 5 (nil -> em dash) and Task 3's non-regression (a present
|
|
17
|
+
# association still renders as a link) in the same fixture.
|
|
18
|
+
class Widget
|
|
19
|
+
extend ActiveModel::Naming
|
|
20
|
+
|
|
21
|
+
attr_reader :id, :name, :count, :status, :company
|
|
22
|
+
|
|
23
|
+
def initialize(id:, name:, count:, status:, company:)
|
|
24
|
+
@id = id
|
|
25
|
+
@name = name
|
|
26
|
+
@count = count
|
|
27
|
+
@status = status
|
|
28
|
+
@company = company
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
ROWS = [
|
|
32
|
+
new(id: 1, name: "Alpha", count: 5, status: "ok", company: LinkingFixtures::Company.new),
|
|
33
|
+
new(id: 2, name: "Beta", count: nil, status: nil, company: nil)
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
def self.all = ReadOnlyResourceFixtures::Relation.new(ROWS)
|
|
37
|
+
def self.column_names = %w[id name count status]
|
|
38
|
+
def self.primary_key = "id"
|
|
39
|
+
def self.columns_hash = { "id" => Struct.new(:type).new(:integer) }
|
|
40
|
+
def self.find(id) = ROWS.find { |w| w.to_param == id.to_s }
|
|
41
|
+
def to_param = id.to_s
|
|
42
|
+
def attributes = { "id" => id, "name" => name, "count" => count, "status" => status }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# A real slicing relation (unlike `ReadOnlyResourceFixtures::Relation`,
|
|
46
|
+
# whose `#offset`/`#limit` are no-ops returning self -- see
|
|
47
|
+
# test-harness fact #8). `Pagy::Backend#pagy_get_items` calls
|
|
48
|
+
# `collection.offset(pagy.offset).limit(pagy.limit)`, so this must
|
|
49
|
+
# actually slice for the per-page tests to prove anything.
|
|
50
|
+
class SlicingRelation
|
|
51
|
+
include Enumerable
|
|
52
|
+
|
|
53
|
+
def initialize(records, offset: 0)
|
|
54
|
+
@records = records
|
|
55
|
+
@offset = offset
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def each(&block) = @records.each(&block)
|
|
59
|
+
def count(*) = @records.length
|
|
60
|
+
def offset(n) = SlicingRelation.new(@records, offset: n)
|
|
61
|
+
def limit(n) = @records[@offset, n] || []
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
class PagedWidget
|
|
65
|
+
extend ActiveModel::Naming
|
|
66
|
+
|
|
67
|
+
attr_reader :id
|
|
68
|
+
|
|
69
|
+
def initialize(id:) = @id = id
|
|
70
|
+
|
|
71
|
+
ALL_ROWS = (1..150).map { |n| new(id: n) }
|
|
72
|
+
|
|
73
|
+
def self.all = SlicingRelation.new(ALL_ROWS)
|
|
74
|
+
def self.column_names = %w[id]
|
|
75
|
+
def self.primary_key = "id"
|
|
76
|
+
def self.columns_hash = { "id" => Struct.new(:type).new(:integer) }
|
|
77
|
+
def self.find(id) = ALL_ROWS.find { |w| w.to_param == id.to_s }
|
|
78
|
+
def to_param = id.to_s
|
|
79
|
+
def attributes = { "id" => id }
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
module Admin
|
|
84
|
+
module Resources
|
|
85
|
+
class IndexTableWidgetResource < Admin::Base::Resource
|
|
86
|
+
model IndexTableFixtures::Widget
|
|
87
|
+
portal :ops
|
|
88
|
+
section :observability
|
|
89
|
+
|
|
90
|
+
index do
|
|
91
|
+
# A filter is required for the sidebar (and thus the per-page
|
|
92
|
+
# selector, which lives inside that same form) to render at all --
|
|
93
|
+
# see `index.html.erb`'s `if index_config&.filters_list&.any? || ...`
|
|
94
|
+
# guard.
|
|
95
|
+
filters { filter :name, type: :text }
|
|
96
|
+
columns do
|
|
97
|
+
column :name, class: "font-mono"
|
|
98
|
+
column :count, align: :right
|
|
99
|
+
column :status, align: :diagonal
|
|
100
|
+
column :company
|
|
101
|
+
end
|
|
102
|
+
paginate 10
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
class IndexTablePagedWidgetResource < Admin::Base::Resource
|
|
107
|
+
model IndexTableFixtures::PagedWidget
|
|
108
|
+
portal :ops
|
|
109
|
+
section :observability
|
|
110
|
+
|
|
111
|
+
index do
|
|
112
|
+
columns { column :id }
|
|
113
|
+
# Deliberately distinct from the hardcoded 25 default and from any
|
|
114
|
+
# of the per_page values under test (10, 50, 100), so a test that
|
|
115
|
+
# asserts "10 rows" can only be passing because the DSL fallback
|
|
116
|
+
# kicked in, not by coincidence with some other default.
|
|
117
|
+
paginate 10
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
module AdminSuite
|
|
124
|
+
class IndexTableTest < ActionDispatch::IntegrationTest
|
|
125
|
+
# Item 1: sticky header.
|
|
126
|
+
test "the index thead carries the sticky header classes" do
|
|
127
|
+
get "/internal/admin_suite/ops/index_table_widgets"
|
|
128
|
+
assert_response :success
|
|
129
|
+
assert_match %r{<thead[^>]*\bsticky\b[^>]*\btop-0\b[^>]*\bz-10\b[^>]*>}, response.body
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# `sticky top-0` only actually pins the header if its nearest scroll
|
|
133
|
+
# container ancestor genuinely scrolls internally. `overflow-x: auto`
|
|
134
|
+
# alone forces the browser to compute `overflow-y` as `auto` too (per
|
|
135
|
+
# the CSS Overflow spec's "visible becomes auto when the other axis
|
|
136
|
+
# isn't visible" rule), making the wrapper div a sticky containing
|
|
137
|
+
# block -- but with no bounded height, that div never scrolls, so the
|
|
138
|
+
# header has nothing to stick against and just scrolls away with the
|
|
139
|
+
# page. This asserts the wrapper is *also* given a bounded height and
|
|
140
|
+
# `overflow-y-auto`, so a future edit that drops the height silently
|
|
141
|
+
# re-breaks sticky (a class-presence-only test on `<thead>` would stay
|
|
142
|
+
# green even if the header stopped sticking) and fails here instead.
|
|
143
|
+
test "the table's scroll wrapper is bounded so the sticky header has something to stick against" do
|
|
144
|
+
get "/internal/admin_suite/ops/index_table_widgets"
|
|
145
|
+
assert_response :success
|
|
146
|
+
assert_match(
|
|
147
|
+
%r{<div class="[^"]*overflow-x-auto[^"]*overflow-y-auto[^"]*max-h-\[70vh\][^"]*"},
|
|
148
|
+
response.body
|
|
149
|
+
)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Item 2: row click wiring, via the already-existing ClickActionsController.
|
|
153
|
+
test "each row wires the click-actions controller to its own show path" do
|
|
154
|
+
get "/internal/admin_suite/ops/index_table_widgets"
|
|
155
|
+
assert_response :success
|
|
156
|
+
|
|
157
|
+
assert_match(/<tr[^>]*data-controller="admin-suite--click-actions"[^>]*>/, response.body)
|
|
158
|
+
# ERB HTML-escapes the `>` in the Stimulus action descriptor, so the
|
|
159
|
+
# attribute renders as `click->...`, not a literal `->`.
|
|
160
|
+
assert_match(/<tr[^>]*data-action="click->admin-suite--click-actions#navigate"[^>]*>/, response.body)
|
|
161
|
+
assert_match(
|
|
162
|
+
%r{<tr[^>]*data-admin-suite--click-actions-url-value="[^"]*index_table_widgets/1"[^>]*>},
|
|
163
|
+
response.body
|
|
164
|
+
)
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Item 3: per-page selector + server-side clamp.
|
|
168
|
+
test "the filter form offers a 25/50/100 per_page selector" do
|
|
169
|
+
get "/internal/admin_suite/ops/index_table_widgets"
|
|
170
|
+
assert_response :success
|
|
171
|
+
assert_match(/<select[^>]*name="per_page"[^>]*>/, response.body)
|
|
172
|
+
assert_match(/<option[^>]*value="25"/, response.body)
|
|
173
|
+
assert_match(/<option[^>]*value="50"/, response.body)
|
|
174
|
+
assert_match(/<option[^>]*value="100"/, response.body)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# Counts `<tr>` inside `<tbody>` only, deliberately independent of item
|
|
178
|
+
# 2's row-click markup (which lands in the same `<tr>` tags) -- so a
|
|
179
|
+
# per_page test failure can never be secretly caused by row-click not
|
|
180
|
+
# being wired yet.
|
|
181
|
+
def row_count(body)
|
|
182
|
+
tbody = body[%r{<tbody.*?</tbody>}m]
|
|
183
|
+
tbody ? tbody.scan(/<tr[ >]/).size : 0
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
test "no per_page param falls back to the DSL's paginate(n) value" do
|
|
187
|
+
get "/internal/admin_suite/ops/index_table_paged_widgets"
|
|
188
|
+
assert_response :success
|
|
189
|
+
assert_equal 10, row_count(response.body)
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
test "a valid per_page renders that many rows" do
|
|
193
|
+
get "/internal/admin_suite/ops/index_table_paged_widgets", params: { per_page: 50 }
|
|
194
|
+
assert_response :success
|
|
195
|
+
assert_equal 50, row_count(response.body)
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
test "per_page above the max clamps to 100, not the requested amount" do
|
|
199
|
+
get "/internal/admin_suite/ops/index_table_paged_widgets", params: { per_page: 999_999 }
|
|
200
|
+
assert_response :success
|
|
201
|
+
assert_equal 100, row_count(response.body)
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
test "per_page=0 falls back to the DSL value instead of an empty/unbounded page" do
|
|
205
|
+
get "/internal/admin_suite/ops/index_table_paged_widgets", params: { per_page: 0 }
|
|
206
|
+
assert_response :success
|
|
207
|
+
assert_equal 10, row_count(response.body)
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
test "a negative per_page falls back to the DSL value" do
|
|
211
|
+
get "/internal/admin_suite/ops/index_table_paged_widgets", params: { per_page: -1 }
|
|
212
|
+
assert_response :success
|
|
213
|
+
assert_equal 10, row_count(response.body)
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
test "a non-numeric per_page falls back to the DSL value" do
|
|
217
|
+
get "/internal/admin_suite/ops/index_table_paged_widgets", params: { per_page: "abc" }
|
|
218
|
+
assert_response :success
|
|
219
|
+
assert_equal 10, row_count(response.body)
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
test "an array-shaped per_page (per_page[]=1) falls back to the DSL value" do
|
|
223
|
+
get "/internal/admin_suite/ops/index_table_paged_widgets", params: { per_page: [ "1" ] }
|
|
224
|
+
assert_response :success
|
|
225
|
+
assert_equal 10, row_count(response.body)
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
# Item 4: column alignment + css_class.
|
|
229
|
+
test "align: :right emits text-right on the td" do
|
|
230
|
+
get "/internal/admin_suite/ops/index_table_widgets"
|
|
231
|
+
assert_response :success
|
|
232
|
+
assert_match(%r{<td class="[^"]*\btext-right\b[^"]*">\s*5\s*</td>}, response.body)
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
test "column.css_class (the class: option) reaches the td" do
|
|
236
|
+
get "/internal/admin_suite/ops/index_table_widgets"
|
|
237
|
+
assert_response :success
|
|
238
|
+
assert_match(%r{<td class="[^"]*\bfont-mono\b[^"]*">}, response.body)
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
test "an unknown align value renders successfully with no fabricated text-<value> class" do
|
|
242
|
+
get "/internal/admin_suite/ops/index_table_widgets"
|
|
243
|
+
assert_response :success
|
|
244
|
+
refute_includes response.body, "text-diagonal"
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
# Isolates a single fixture row's `<tr>...</tr>` markup by a distinctive
|
|
248
|
+
# cell value, so nil-cell assertions can target Beta's row specifically
|
|
249
|
+
# instead of matching the first empty-looking `<td>` anywhere on the
|
|
250
|
+
# page (there are several: Alpha's row has none, but a loose regex
|
|
251
|
+
# would not tell the two rows apart).
|
|
252
|
+
def widget_row(body, marker)
|
|
253
|
+
tbody = body[%r{<tbody.*?</tbody>}m]
|
|
254
|
+
tbody.scan(%r{<tr.*?</tr>}m).find { |r| r.include?(marker) }
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
# Item 5: nil cells render an em dash, and Task 3's association links
|
|
258
|
+
# are not regressed by that change.
|
|
259
|
+
test "a nil scalar column renders an em dash, not a blank cell" do
|
|
260
|
+
get "/internal/admin_suite/ops/index_table_widgets"
|
|
261
|
+
assert_response :success
|
|
262
|
+
beta_row = widget_row(response.body, "Beta")
|
|
263
|
+
refute_nil beta_row, "expected to find Beta's row"
|
|
264
|
+
# Beta's `count` is nil -- the fallback branch must turn that into an
|
|
265
|
+
# em dash instead of the empty string it renders today.
|
|
266
|
+
assert_match(%r{<td[^>]*>\s*—\s*</td>}, beta_row)
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
test "a nil association column renders an em dash, not a blank cell" do
|
|
270
|
+
get "/internal/admin_suite/ops/index_table_widgets"
|
|
271
|
+
assert_response :success
|
|
272
|
+
beta_row = widget_row(response.body, "Beta")
|
|
273
|
+
refute_nil beta_row, "expected to find Beta's row"
|
|
274
|
+
# Beta's `company` is nil (Task 3 deliberately left nil association
|
|
275
|
+
# handling to this task). Count how many dash-only cells the row has:
|
|
276
|
+
# count and status are also nil, so there must be at least 3 (not 2),
|
|
277
|
+
# proving company's cell got the dash too rather than staying blank.
|
|
278
|
+
dash_cells = beta_row.scan(%r{<td[^>]*>\s*—\s*</td>}).size
|
|
279
|
+
assert_equal 3, dash_cells,
|
|
280
|
+
"expected 3 dash cells (count, status, company all nil) in Beta's row, got #{dash_cells}"
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
test "a present association still renders as a link (Task 3 non-regression)" do
|
|
284
|
+
get "/internal/admin_suite/ops/index_table_widgets"
|
|
285
|
+
assert_response :success
|
|
286
|
+
assert_match %r{<a[^>]+href="[^"]*linking_companies/7"[^>]*>\s*Acme Corp\s*</a>}, response.body
|
|
287
|
+
end
|
|
288
|
+
end
|
|
289
|
+
end
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "test_helper"
|
|
4
|
+
|
|
5
|
+
# A minimal ActiveModel-backed fixture with two markdown fields, registered
|
|
6
|
+
# as a real resource so we can render an actual /new page through the full
|
|
7
|
+
# stack and assert the vendored EasyMDE tags appear (not just that the files
|
|
8
|
+
# exist on disk, and not just that a markdown-less page omits them).
|
|
9
|
+
#
|
|
10
|
+
# Per Task 9's ledger: `Admin::Base::Resource` subclasses register into a
|
|
11
|
+
# process-global registry via `Class#inherited`, which fires once at class
|
|
12
|
+
# definition (here, at file load) and never again -- so this is safe to
|
|
13
|
+
# define at the top level like `ReadOnlyResourceFixtures`/`ReadOnlyWidgetResource`
|
|
14
|
+
# in test/integration/read_only_resource_test.rb, and doesn't interact badly
|
|
15
|
+
# with the nav-rebuild hazard documented there (this resource is never wiped
|
|
16
|
+
# or reloaded mid-run).
|
|
17
|
+
module LayoutAssetsFixtures
|
|
18
|
+
class MarkdownWidget
|
|
19
|
+
extend ActiveModel::Naming
|
|
20
|
+
include ActiveModel::Conversion
|
|
21
|
+
|
|
22
|
+
attr_accessor :id, :body, :notes
|
|
23
|
+
|
|
24
|
+
def initialize(id: nil, body: nil, notes: nil)
|
|
25
|
+
@id = id
|
|
26
|
+
@body = body
|
|
27
|
+
@notes = notes
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def self.column_names
|
|
31
|
+
%w[id body notes]
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def persisted?
|
|
35
|
+
!id.nil?
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def new_record?
|
|
39
|
+
!persisted?
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# render_form_field unconditionally calls `resource.errors[field.name]`
|
|
43
|
+
# (to add the error border class / message) for every field type; no
|
|
44
|
+
# test here exercises a validation error, so a Hash defaulting to `[]`
|
|
45
|
+
# is sufficient (same rationale as form_field_renderer_test.rb's Record).
|
|
46
|
+
def errors
|
|
47
|
+
Hash.new([])
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
module Admin
|
|
53
|
+
module Resources
|
|
54
|
+
class MarkdownWidgetResource < Admin::Base::Resource
|
|
55
|
+
model LayoutAssetsFixtures::MarkdownWidget
|
|
56
|
+
portal :ops
|
|
57
|
+
section :observability
|
|
58
|
+
|
|
59
|
+
# Two markdown fields on the same form: exercises the
|
|
60
|
+
# `content_for?(:easymde_assets)` dedup guard in
|
|
61
|
+
# AdminSuite::UI::FieldRendererRegistry's :markdown handler.
|
|
62
|
+
form do
|
|
63
|
+
field :body, type: :markdown
|
|
64
|
+
field :notes, type: :markdown
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
module AdminSuite
|
|
71
|
+
class LayoutAssetsTest < ActionDispatch::IntegrationTest
|
|
72
|
+
test "the admin layout requests no third-party CDN assets" do
|
|
73
|
+
get "/internal/admin_suite"
|
|
74
|
+
assert_response :success
|
|
75
|
+
refute_includes response.body, "cdn.jsdelivr.net"
|
|
76
|
+
refute_match(%r{<script[^>]+src="https?://(?!localhost)}, response.body)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
test "EasyMDE ships as a vendored engine asset" do
|
|
80
|
+
assert AdminSuite::Engine.root.join("app/assets/vendor/easymde.min.js").exist?
|
|
81
|
+
assert AdminSuite::Engine.root.join("app/assets/vendor/easymde.min.css").exist?
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
test "the markdown controller bounds its editor-availability retry" do
|
|
85
|
+
js = AdminSuite::Engine.root.join("app/javascript/controllers/admin_suite/markdown_editor_controller.js").read
|
|
86
|
+
assert_match(/attempts|retries|maxWait/i, js)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
test "a page rendering a markdown field loads the vendored EasyMDE assets, and still no CDN" do
|
|
90
|
+
get "/internal/admin_suite/ops/markdown_widgets/new"
|
|
91
|
+
assert_response :success
|
|
92
|
+
|
|
93
|
+
refute_includes response.body, "cdn.jsdelivr.net"
|
|
94
|
+
assert_match(%r{<link[^>]+href="[^"]*vendor/easymde\.min[^"]*\.css"}, response.body)
|
|
95
|
+
assert_match(%r{<script[^>]+src="[^"]*vendor/easymde\.min[^"]*\.js"}, response.body)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
test "multiple markdown fields on one form emit the EasyMDE assets exactly once" do
|
|
99
|
+
get "/internal/admin_suite/ops/markdown_widgets/new"
|
|
100
|
+
assert_response :success
|
|
101
|
+
|
|
102
|
+
assert_equal 1, response.body.scan(%r{<link[^>]+href="[^"]*vendor/easymde\.min[^"]*\.css"}).size
|
|
103
|
+
assert_equal 1, response.body.scan(%r{<script[^>]+src="[^"]*vendor/easymde\.min[^"]*\.js"}).size
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
test "a page without a markdown field never references EasyMDE" do
|
|
107
|
+
get "/internal/admin_suite"
|
|
108
|
+
assert_response :success
|
|
109
|
+
refute_includes response.body, "vendor/easymde.min"
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "test_helper"
|
|
4
|
+
|
|
5
|
+
module AdminSuite
|
|
6
|
+
class NavigationSectionsTest < ActionDispatch::IntegrationTest
|
|
7
|
+
setup do
|
|
8
|
+
AdminSuite::PortalRegistry.reset!
|
|
9
|
+
AdminSuite.portal :ops do
|
|
10
|
+
label "Ops"
|
|
11
|
+
section :zzz_last do
|
|
12
|
+
label "Aaa Displayed First"
|
|
13
|
+
order 1
|
|
14
|
+
end
|
|
15
|
+
section :observability do
|
|
16
|
+
label "Observability"
|
|
17
|
+
order 50
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
teardown { AdminSuite::PortalRegistry.reset! }
|
|
23
|
+
|
|
24
|
+
test "declared sections use their label and order, not alphabetical keys" do
|
|
25
|
+
get "/internal/admin_suite/ops"
|
|
26
|
+
assert_response :success
|
|
27
|
+
first = response.body.index("Aaa Displayed First")
|
|
28
|
+
second = response.body.index("Observability")
|
|
29
|
+
assert first, "declared section label missing"
|
|
30
|
+
assert second, "second section label missing"
|
|
31
|
+
assert first < second, "sections must honour declared order, not label sort"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
test "undeclared sections still auto-synthesize a humanized label" do
|
|
35
|
+
AdminSuite::PortalRegistry.reset!
|
|
36
|
+
get "/internal/admin_suite/ops"
|
|
37
|
+
assert_response :success
|
|
38
|
+
assert_includes response.body, "Observability" # from ReadOnlyWidgetResource's `section :observability`
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# `:zzz_last` (declared in `setup` above) has no resources registered
|
|
42
|
+
# under it anywhere in the suite, unlike `:observability`. Before this
|
|
43
|
+
# fix, `_sidebar.html.erb` rendered a bare section label with nothing
|
|
44
|
+
# underneath for a section like this; `portals/show.html.erb` already
|
|
45
|
+
# showed "No resources in this section yet." for the same case, so the
|
|
46
|
+
# sidebar was the odd one out.
|
|
47
|
+
test "the sidebar shows the no-resources message for a declared but empty section" do
|
|
48
|
+
get "/internal/admin_suite/ops"
|
|
49
|
+
assert_response :success
|
|
50
|
+
|
|
51
|
+
# Scoped to the sidebar itself: `portals/show.html.erb`'s own fallback
|
|
52
|
+
# section list also renders "No resources in this section yet." for
|
|
53
|
+
# the same empty section, so an unscoped `assert_includes` on the full
|
|
54
|
+
# body wouldn't actually prove the *sidebar* (as opposed to the main
|
|
55
|
+
# content) says it.
|
|
56
|
+
sidebar = Nokogiri::HTML(response.body).at_css(".admin-suite-sidebar")
|
|
57
|
+
assert sidebar, "expected to find the sidebar"
|
|
58
|
+
assert_includes sidebar.text, "Aaa Displayed First"
|
|
59
|
+
assert_includes sidebar.text, "No resources in this section yet."
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "test_helper"
|
|
4
|
+
|
|
5
|
+
# Self-contained fixture, following the pattern established in
|
|
6
|
+
# authorization_test.rb/read_only_resource_test.rb: each integration test
|
|
7
|
+
# file defines its own model + resource rather than depending on another
|
|
8
|
+
# test file's fixture being loaded. Rake's TEST= support (rake/testtask.rb)
|
|
9
|
+
# replaces the whole file list with just the one file requested, so a
|
|
10
|
+
# fixture defined only in a sibling test file (e.g. ReadOnlyWidgetResource
|
|
11
|
+
# in read_only_resource_test.rb) does not exist when this file is run in
|
|
12
|
+
# isolation with `rake test TEST=test/integration/pagination_and_stats_test.rb`
|
|
13
|
+
# (the exact command this task's brief calls for) — that dependency was a
|
|
14
|
+
# plan-authored test defect, fixed here rather than in the implementation.
|
|
15
|
+
module PaginationStatsFixtures
|
|
16
|
+
class Widget
|
|
17
|
+
extend ActiveModel::Naming
|
|
18
|
+
|
|
19
|
+
attr_reader :id, :name
|
|
20
|
+
|
|
21
|
+
def initialize(id: 1, name: "Observed widget")
|
|
22
|
+
@id = id
|
|
23
|
+
@name = name
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def self.all = ReadOnlyResourceFixtures::Relation.new((1..25).map { |n| new(id: n, name: "Observed widget #{n}") })
|
|
27
|
+
def self.column_names = %w[id name]
|
|
28
|
+
def self.primary_key = "id"
|
|
29
|
+
def self.columns_hash = { "id" => Struct.new(:type).new(:integer) }
|
|
30
|
+
|
|
31
|
+
def self.find(id)
|
|
32
|
+
raise ActiveRecord::RecordNotFound unless id.to_s == "1"
|
|
33
|
+
|
|
34
|
+
new
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def to_param = id.to_s
|
|
38
|
+
def attributes = { "id" => id, "name" => name }
|
|
39
|
+
|
|
40
|
+
# A plain Array, not ReadOnlyResourceFixtures::Relation: render_association_section
|
|
41
|
+
# slices it via `Array.wrap(associated)[pagy.offset, per_page]` (the
|
|
42
|
+
# branch taken when the association doesn't respond to :offset), which
|
|
43
|
+
# exercises real pagination slicing rather than the Relation stand-in's
|
|
44
|
+
# no-op #offset/#limit.
|
|
45
|
+
def parts
|
|
46
|
+
(1..5).map { |n| Part.new(n) }
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Plain (non-ActiveRecord) associated record: exercises
|
|
51
|
+
# render_association_section's pagination path, the sole surviving caller
|
|
52
|
+
# of the deleted pagy_prev_link/pagy_next_link/pagy_page_links/
|
|
53
|
+
# render_pagy_series_item/render_association_pagination helpers.
|
|
54
|
+
class Part
|
|
55
|
+
attr_reader :id, :name
|
|
56
|
+
|
|
57
|
+
def initialize(id)
|
|
58
|
+
@id = id
|
|
59
|
+
@name = "Part #{id}"
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
module Admin
|
|
65
|
+
module Resources
|
|
66
|
+
class PaginationStatsWidgetResource < Admin::Base::Resource
|
|
67
|
+
model PaginationStatsFixtures::Widget
|
|
68
|
+
portal :ops
|
|
69
|
+
section :observability
|
|
70
|
+
read_only
|
|
71
|
+
|
|
72
|
+
index do
|
|
73
|
+
columns { column :name }
|
|
74
|
+
paginate 10
|
|
75
|
+
stats do
|
|
76
|
+
stat :total, -> { 7 }, color: :indigo
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
show do
|
|
81
|
+
section :parts, association: :parts, paginate: true, per_page: 2
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
module AdminSuite
|
|
88
|
+
class PaginationAndStatsTest < ActionDispatch::IntegrationTest
|
|
89
|
+
test "index stats render without dynamic tailwind color classes" do
|
|
90
|
+
get "/internal/admin_suite/ops/pagination_stats_widgets"
|
|
91
|
+
assert_response :success
|
|
92
|
+
refute_match(/class="[^"]*text-\{/, response.body)
|
|
93
|
+
# A real stat, with a real (new) color, rendered end to end through
|
|
94
|
+
# AdminSuite::UI::PanelDefinition + admin_suite/panels/_stat — not
|
|
95
|
+
# just a source-text check that the dynamic interpolation is gone.
|
|
96
|
+
assert_includes response.body, "text-indigo-700"
|
|
97
|
+
assert_includes response.body, ">7<"
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
test "the index stats markup comes from the shared stat partial" do
|
|
101
|
+
source = AdminSuite::Engine.root.join("app/views/admin_suite/resources/index.html.erb").read
|
|
102
|
+
refute_includes source, 'text-<%= color %>-600'
|
|
103
|
+
assert_includes source, "admin_suite/panels/stat"
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
test "pagination markup is a single shared partial" do
|
|
107
|
+
index = AdminSuite::Engine.root.join("app/views/admin_suite/resources/index.html.erb").read
|
|
108
|
+
helper = AdminSuite::Engine.root.join("app/helpers/admin_suite/base_helper.rb").read
|
|
109
|
+
assert_includes index, "admin_suite/shared/pagination"
|
|
110
|
+
refute_includes helper, "def pagy_prev_link"
|
|
111
|
+
refute_includes helper, "def render_pagy_series_item"
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# The brief's original version of this test rendered the partial in
|
|
115
|
+
# isolation via `ApplicationController.render(partial:, locals:)`. That
|
|
116
|
+
# bare renderer has no routed request, so `params` carries no
|
|
117
|
+
# :controller/:action, and the partial's `url_for(params.permit!.merge(...))`
|
|
118
|
+
# calls (verbatim from the index view, per the brief's Step 3) raise
|
|
119
|
+
# `ActionController::UrlGenerationError: No route matches`. That is a
|
|
120
|
+
# second plan-authored test defect: fixed here by exercising the partial
|
|
121
|
+
# through a real routed request, which is how both callers (index,
|
|
122
|
+
# association panels) actually invoke it.
|
|
123
|
+
test "the shared pagination partial renders prev, next and the series" do
|
|
124
|
+
get "/internal/admin_suite/ops/pagination_stats_widgets", params: { page: 2 }
|
|
125
|
+
assert_response :success
|
|
126
|
+
assert_includes response.body, "Prev"
|
|
127
|
+
assert_includes response.body, "Next"
|
|
128
|
+
assert_includes response.body, "3"
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
test "association panel pagination renders through the same shared partial" do
|
|
132
|
+
get "/internal/admin_suite/ops/pagination_stats_widgets/1", params: { parts_page: 2 }
|
|
133
|
+
assert_response :success
|
|
134
|
+
assert_includes response.body, "Prev"
|
|
135
|
+
assert_includes response.body, "Next"
|
|
136
|
+
# Richer, index-derived markup that render_association_pagination
|
|
137
|
+
# (now deleted) never rendered: the "Showing X to Y of Z results"
|
|
138
|
+
# summary line (5 parts, 2 per page, page 2 => items 3-4).
|
|
139
|
+
assert_includes response.body, "Showing"
|
|
140
|
+
assert_includes response.body, "results"
|
|
141
|
+
assert_includes response.body, "3"
|
|
142
|
+
assert_includes response.body, "4"
|
|
143
|
+
assert_includes response.body, "5"
|
|
144
|
+
# The part most likely to regress: the per-association page param
|
|
145
|
+
# (association_page_param -> "#{section.association}_page"), not the
|
|
146
|
+
# generic :page the index uses.
|
|
147
|
+
assert_includes response.body, "parts_page=1"
|
|
148
|
+
assert_includes response.body, "parts_page=3"
|
|
149
|
+
refute_includes response.body, "?page=1"
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|