rsb-admin 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (115) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +15 -0
  3. data/README.md +83 -0
  4. data/Rakefile +25 -0
  5. data/app/assets/javascripts/rsb/admin/themes/modern.js +37 -0
  6. data/app/assets/stylesheets/rsb/admin/themes/default.css +1358 -0
  7. data/app/assets/stylesheets/rsb/admin/themes/modern.css +1370 -0
  8. data/app/controllers/concerns/rsb/admin/authorization.rb +21 -0
  9. data/app/controllers/rsb/admin/admin_controller.rb +138 -0
  10. data/app/controllers/rsb/admin/admin_users_controller.rb +110 -0
  11. data/app/controllers/rsb/admin/dashboard_controller.rb +76 -0
  12. data/app/controllers/rsb/admin/profile_controller.rb +146 -0
  13. data/app/controllers/rsb/admin/profile_sessions_controller.rb +45 -0
  14. data/app/controllers/rsb/admin/resources_controller.rb +386 -0
  15. data/app/controllers/rsb/admin/roles_controller.rb +99 -0
  16. data/app/controllers/rsb/admin/sessions_controller.rb +139 -0
  17. data/app/controllers/rsb/admin/settings_controller.rb +203 -0
  18. data/app/controllers/rsb/admin/two_factor_controller.rb +105 -0
  19. data/app/helpers/rsb/admin/authorization_helper.rb +49 -0
  20. data/app/helpers/rsb/admin/branding_helper.rb +38 -0
  21. data/app/helpers/rsb/admin/formatting_helper.rb +205 -0
  22. data/app/helpers/rsb/admin/i18n_helper.rb +148 -0
  23. data/app/helpers/rsb/admin/icons_helper.rb +55 -0
  24. data/app/helpers/rsb/admin/table_helper.rb +132 -0
  25. data/app/helpers/rsb/admin/theme_helper.rb +84 -0
  26. data/app/helpers/rsb/admin/url_helper.rb +109 -0
  27. data/app/mailers/rsb/admin/admin_mailer.rb +37 -0
  28. data/app/models/rsb/admin/admin_session.rb +109 -0
  29. data/app/models/rsb/admin/admin_user.rb +153 -0
  30. data/app/models/rsb/admin/application_record.rb +10 -0
  31. data/app/models/rsb/admin/role.rb +63 -0
  32. data/app/views/layouts/rsb/admin/application.html.erb +45 -0
  33. data/app/views/rsb/admin/admin_mailer/email_verification.html.erb +11 -0
  34. data/app/views/rsb/admin/admin_mailer/email_verification.text.erb +11 -0
  35. data/app/views/rsb/admin/admin_users/_form.html.erb +52 -0
  36. data/app/views/rsb/admin/admin_users/edit.html.erb +10 -0
  37. data/app/views/rsb/admin/admin_users/index.html.erb +77 -0
  38. data/app/views/rsb/admin/admin_users/new.html.erb +10 -0
  39. data/app/views/rsb/admin/admin_users/show.html.erb +85 -0
  40. data/app/views/rsb/admin/dashboard/index.html.erb +36 -0
  41. data/app/views/rsb/admin/profile/edit.html.erb +67 -0
  42. data/app/views/rsb/admin/profile/show.html.erb +155 -0
  43. data/app/views/rsb/admin/resources/_filters.html.erb +58 -0
  44. data/app/views/rsb/admin/resources/_form.html.erb +20 -0
  45. data/app/views/rsb/admin/resources/_pagination.html.erb +33 -0
  46. data/app/views/rsb/admin/resources/_table.html.erb +70 -0
  47. data/app/views/rsb/admin/resources/edit.html.erb +7 -0
  48. data/app/views/rsb/admin/resources/index.html.erb +49 -0
  49. data/app/views/rsb/admin/resources/new.html.erb +7 -0
  50. data/app/views/rsb/admin/resources/page.html.erb +9 -0
  51. data/app/views/rsb/admin/resources/show.html.erb +55 -0
  52. data/app/views/rsb/admin/roles/_form.html.erb +197 -0
  53. data/app/views/rsb/admin/roles/edit.html.erb +7 -0
  54. data/app/views/rsb/admin/roles/index.html.erb +71 -0
  55. data/app/views/rsb/admin/roles/new.html.erb +7 -0
  56. data/app/views/rsb/admin/roles/show.html.erb +99 -0
  57. data/app/views/rsb/admin/sessions/new.html.erb +31 -0
  58. data/app/views/rsb/admin/sessions/two_factor.html.erb +39 -0
  59. data/app/views/rsb/admin/settings/_field.html.erb +115 -0
  60. data/app/views/rsb/admin/settings/index.html.erb +61 -0
  61. data/app/views/rsb/admin/shared/_badge.html.erb +1 -0
  62. data/app/views/rsb/admin/shared/_breadcrumbs.html.erb +12 -0
  63. data/app/views/rsb/admin/shared/_empty_state.html.erb +4 -0
  64. data/app/views/rsb/admin/shared/_flash.html.erb +22 -0
  65. data/app/views/rsb/admin/shared/_header.html.erb +50 -0
  66. data/app/views/rsb/admin/shared/_page_tabs.html.erb +21 -0
  67. data/app/views/rsb/admin/shared/_sidebar.html.erb +99 -0
  68. data/app/views/rsb/admin/shared/disabled.html.erb +38 -0
  69. data/app/views/rsb/admin/shared/fields/_checkbox.html.erb +6 -0
  70. data/app/views/rsb/admin/shared/fields/_datetime.html.erb +10 -0
  71. data/app/views/rsb/admin/shared/fields/_email.html.erb +10 -0
  72. data/app/views/rsb/admin/shared/fields/_hidden.html.erb +1 -0
  73. data/app/views/rsb/admin/shared/fields/_json.html.erb +11 -0
  74. data/app/views/rsb/admin/shared/fields/_number.html.erb +10 -0
  75. data/app/views/rsb/admin/shared/fields/_password.html.erb +10 -0
  76. data/app/views/rsb/admin/shared/fields/_select.html.erb +12 -0
  77. data/app/views/rsb/admin/shared/fields/_text.html.erb +10 -0
  78. data/app/views/rsb/admin/shared/fields/_textarea.html.erb +10 -0
  79. data/app/views/rsb/admin/shared/forbidden.html.erb +22 -0
  80. data/app/views/rsb/admin/themes/modern/views/shared/_header.html.erb +77 -0
  81. data/app/views/rsb/admin/themes/modern/views/shared/_sidebar.html.erb +135 -0
  82. data/app/views/rsb/admin/two_factor/backup_codes.html.erb +48 -0
  83. data/app/views/rsb/admin/two_factor/new.html.erb +53 -0
  84. data/config/locales/en.yml +140 -0
  85. data/config/locales/seo.en.yml +21 -0
  86. data/config/routes.rb +59 -0
  87. data/db/migrate/20260208000003_create_rsb_admin_tables.rb +43 -0
  88. data/db/migrate/20260214000001_add_otp_fields_to_rsb_admin_admin_users.rb +9 -0
  89. data/lib/generators/rsb/admin/install/install_generator.rb +45 -0
  90. data/lib/generators/rsb/admin/install/templates/rsb_admin_seeds.rb +24 -0
  91. data/lib/generators/rsb/admin/theme/templates/theme.css.tt +66 -0
  92. data/lib/generators/rsb/admin/theme/theme_generator.rb +218 -0
  93. data/lib/generators/rsb/admin/views/views_generator.rb +262 -0
  94. data/lib/rsb/admin/breadcrumb_item.rb +26 -0
  95. data/lib/rsb/admin/category_registration.rb +177 -0
  96. data/lib/rsb/admin/column_definition.rb +89 -0
  97. data/lib/rsb/admin/configuration.rb +69 -0
  98. data/lib/rsb/admin/engine.rb +34 -0
  99. data/lib/rsb/admin/filter_definition.rb +129 -0
  100. data/lib/rsb/admin/form_field_definition.rb +96 -0
  101. data/lib/rsb/admin/icons.rb +95 -0
  102. data/lib/rsb/admin/page_registration.rb +140 -0
  103. data/lib/rsb/admin/registry.rb +109 -0
  104. data/lib/rsb/admin/resource_dsl_context.rb +139 -0
  105. data/lib/rsb/admin/resource_registration.rb +287 -0
  106. data/lib/rsb/admin/settings_schema.rb +60 -0
  107. data/lib/rsb/admin/test_kit/helpers.rb +316 -0
  108. data/lib/rsb/admin/test_kit/resource_test_case.rb +193 -0
  109. data/lib/rsb/admin/test_kit.rb +11 -0
  110. data/lib/rsb/admin/theme_definition.rb +46 -0
  111. data/lib/rsb/admin/themes/modern.rb +44 -0
  112. data/lib/rsb/admin/version.rb +9 -0
  113. data/lib/rsb/admin.rb +177 -0
  114. data/lib/tasks/rsb/admin_tasks.rake +23 -0
  115. metadata +227 -0
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSB
4
+ module Admin
5
+ class SettingsSchema
6
+ def self.build
7
+ RSB::Settings::Schema.new('admin') do
8
+ setting :enabled,
9
+ type: :boolean,
10
+ default: true,
11
+ group: 'General',
12
+ description: 'Enable or disable the admin panel'
13
+
14
+ setting :app_name,
15
+ type: :string,
16
+ default: 'Admin',
17
+ group: 'Branding',
18
+ description: 'Admin panel title'
19
+
20
+ setting :company_name,
21
+ type: :string,
22
+ default: '',
23
+ group: 'Branding',
24
+ description: 'Company or product name'
25
+
26
+ setting :logo_url,
27
+ type: :string,
28
+ default: '',
29
+ group: 'Branding',
30
+ description: 'URL to logo image (sidebar header)'
31
+
32
+ setting :footer_text,
33
+ type: :string,
34
+ default: '',
35
+ group: 'Branding',
36
+ description: 'Custom footer text'
37
+
38
+ setting :theme,
39
+ type: :string,
40
+ default: 'default',
41
+ enum: -> { RSB::Admin.themes.keys.map(&:to_s) },
42
+ group: 'General',
43
+ description: 'Admin panel theme'
44
+
45
+ setting :per_page,
46
+ type: :integer,
47
+ default: 25,
48
+ group: 'General',
49
+ description: 'Default pagination size'
50
+
51
+ setting :require_two_factor,
52
+ type: :boolean,
53
+ default: false,
54
+ group: 'Security',
55
+ description: 'Require all admin users to enable two-factor authentication'
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,316 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSB
4
+ module Admin
5
+ module TestKit
6
+ module Helpers
7
+ extend ActiveSupport::Concern
8
+
9
+ included do
10
+ teardown do
11
+ RSB::Admin.reset!
12
+ end
13
+ end
14
+
15
+ def create_test_admin!(permissions: nil, superadmin: false, no_role: false, email: nil,
16
+ password: 'test-password-secure')
17
+ email ||= "test-admin-#{SecureRandom.hex(4)}@example.com"
18
+
19
+ if no_role
20
+ return RSB::Admin::AdminUser.create!(
21
+ email: email,
22
+ password: password,
23
+ password_confirmation: password
24
+ )
25
+ end
26
+
27
+ if superadmin || permissions.nil?
28
+ role = RSB::Admin::Role.create!(
29
+ name: "Test Superadmin #{SecureRandom.hex(4)}",
30
+ permissions: { '*' => ['*'] }
31
+ )
32
+ else
33
+ # For empty permissions, use a sentinel value that passes validation
34
+ perm = permissions.empty? ? { '_none' => [] } : permissions
35
+ role = RSB::Admin::Role.create!(
36
+ name: "Test Role #{SecureRandom.hex(4)}",
37
+ permissions: perm
38
+ )
39
+ end
40
+
41
+ RSB::Admin::AdminUser.create!(
42
+ email: email,
43
+ password: password,
44
+ password_confirmation: password,
45
+ role: role
46
+ )
47
+ end
48
+
49
+ def sign_in_admin(admin, password: 'test-password-secure')
50
+ post rsb_admin.login_path, params: { email: admin.email, password: password }
51
+ end
52
+
53
+ def assert_admin_authorized
54
+ assert_response :success
55
+ end
56
+
57
+ # Assert that admin access was denied.
58
+ #
59
+ # Checks that the response is either a redirect (302, for unauthenticated users
60
+ # redirected to login) or a forbidden response (403, for authenticated users
61
+ # without permission). After RFC-002, 403 responses include a rendered
62
+ # forbidden page body.
63
+ #
64
+ # @return [void]
65
+ #
66
+ # @raise [Minitest::Assertion] if response is not 302 or 403
67
+ #
68
+ # @example Verify a restricted admin is denied
69
+ # restricted = create_test_admin!(permissions: { "dashboard" => ["index"] })
70
+ # sign_in_admin(restricted)
71
+ # get "/admin/roles"
72
+ # assert_admin_denied
73
+ def assert_admin_denied
74
+ assert_includes [302, 403], response.status
75
+ end
76
+
77
+ # Assert that the forbidden page is rendered with proper content.
78
+ #
79
+ # Verifies that the response has a 403 status and contains the expected
80
+ # forbidden page elements: "Access Denied" title and explanation message.
81
+ # Optionally checks for the presence or absence of the "Go to Dashboard" link.
82
+ #
83
+ # @param dashboard_link [Boolean, nil] if true, asserts dashboard link is present;
84
+ # if false, asserts it's absent; if nil, doesn't check (default: nil)
85
+ #
86
+ # @return [void]
87
+ #
88
+ # @raise [Minitest::Assertion] if forbidden page is not rendered correctly
89
+ #
90
+ # @example Basic forbidden page check
91
+ # get "/admin/roles"
92
+ # assert_admin_forbidden_page
93
+ #
94
+ # @example Verify no dashboard link for no-role user
95
+ # get "/admin/dashboard"
96
+ # assert_admin_forbidden_page(dashboard_link: false)
97
+ #
98
+ # @example Verify dashboard link present
99
+ # get "/admin/roles"
100
+ # assert_admin_forbidden_page(dashboard_link: true)
101
+ def assert_admin_forbidden_page(dashboard_link: nil)
102
+ assert_response :forbidden
103
+
104
+ # Use assert_select to properly handle HTML entities
105
+ assert_select 'h1', text: I18n.t('rsb.admin.shared.access_denied'),
106
+ message: "Expected 'Access Denied' title in forbidden page"
107
+ assert_select 'p', text: I18n.t('rsb.admin.shared.access_denied_message'),
108
+ message: 'Expected explanation message in forbidden page'
109
+
110
+ return if dashboard_link.nil?
111
+
112
+ if dashboard_link
113
+ assert_select 'a', text: I18n.t('rsb.admin.shared.go_to_dashboard'),
114
+ message: "Expected 'Go to Dashboard' link in forbidden page"
115
+ else
116
+ assert_select 'a', text: I18n.t('rsb.admin.shared.go_to_dashboard'), count: 0,
117
+ message: "Expected NO 'Go to Dashboard' link in forbidden page"
118
+ end
119
+ end
120
+
121
+ def assert_admin_resource_registered(model_class, category:)
122
+ registration = RSB::Admin.registry.find_resource(model_class)
123
+ assert registration, "#{model_class} not registered in admin"
124
+ assert_equal category, registration.category_name
125
+ end
126
+
127
+ def with_fresh_admin_registry
128
+ old_registry = RSB::Admin.registry
129
+ RSB::Admin.instance_variable_set(:@registry, RSB::Admin::Registry.new)
130
+ yield RSB::Admin.registry
131
+ ensure
132
+ RSB::Admin.instance_variable_set(:@registry, old_registry)
133
+ end
134
+
135
+ # Assert that a column header appears in the response body.
136
+ #
137
+ # This method verifies that a table column with the given label is rendered
138
+ # in the current response. It uses `assert_select` to check for a `<th>` tag
139
+ # containing the label text (case-insensitive). Useful for verifying that
140
+ # registered columns appear in resource index views.
141
+ #
142
+ # @param label [String] the column header text to look for
143
+ #
144
+ # @return [void]
145
+ #
146
+ # @raise [Minitest::Assertion] if the column header is not found
147
+ #
148
+ # @example Verify email column renders
149
+ # get "/admin/users"
150
+ # assert_admin_column_rendered("Email")
151
+ #
152
+ # @example Verify custom column label
153
+ # get "/admin/identities"
154
+ # assert_admin_column_rendered("Identity Email")
155
+ def assert_admin_column_rendered(label)
156
+ assert_select 'th', text: /#{Regexp.escape(label)}/i,
157
+ message: "Expected column '#{label}' in table header"
158
+ end
159
+
160
+ # Assert that a filter input exists in the response.
161
+ #
162
+ # This method verifies that a filter form field with the given key is rendered
163
+ # in the current response. It uses `assert_select` to check for an input/select
164
+ # element with a name attribute matching the filter parameter format `q[key]`.
165
+ # Useful for verifying that registered filters appear in resource index views.
166
+ #
167
+ # @param key [String, Symbol] the filter key to look for
168
+ #
169
+ # @return [void]
170
+ #
171
+ # @raise [Minitest::Assertion] if the filter input is not found
172
+ #
173
+ # @example Verify email filter renders
174
+ # get "/admin/users"
175
+ # assert_admin_filter_rendered("email")
176
+ #
177
+ # @example Verify status filter renders
178
+ # get "/admin/identities"
179
+ # assert_admin_filter_rendered(:status)
180
+ def assert_admin_filter_rendered(key)
181
+ assert_select "[name*='q[#{key}]']",
182
+ message: "Expected filter for '#{key}'"
183
+ end
184
+
185
+ # Assert that breadcrumbs contain specific items.
186
+ #
187
+ # This method verifies that the breadcrumb navigation contains the given labels
188
+ # in the current response body. It performs a simple text match for each label,
189
+ # which is sufficient since breadcrumbs render their labels as plain text.
190
+ # Useful for verifying navigation context in admin views.
191
+ #
192
+ # @param labels [Array<String>] one or more breadcrumb labels to look for
193
+ #
194
+ # @return [void]
195
+ #
196
+ # @raise [Minitest::Assertion] if any breadcrumb label is not found
197
+ #
198
+ # @example Verify dashboard breadcrumb
199
+ # get "/admin/users"
200
+ # assert_admin_breadcrumbs("Dashboard")
201
+ #
202
+ # @example Verify full breadcrumb trail
203
+ # get "/admin/identities/123"
204
+ # assert_admin_breadcrumbs("Dashboard", "Authentication", "Identities", "#123")
205
+ def assert_admin_breadcrumbs(*labels)
206
+ labels.each do |label|
207
+ assert_match label, response.body,
208
+ "Expected breadcrumb '#{label}' in response"
209
+ end
210
+ end
211
+
212
+ # Assert that a form field exists in the response.
213
+ #
214
+ # This method verifies that a form field with the given key is rendered
215
+ # in the current response. It uses `assert_select` to check for an input/select/textarea
216
+ # element with a name attribute containing the field key in Rails form format `[key]`.
217
+ # Useful for verifying that registered form fields appear in new/edit views.
218
+ #
219
+ # @param key [String, Symbol] the form field key to look for
220
+ #
221
+ # @return [void]
222
+ #
223
+ # @raise [Minitest::Assertion] if the form field is not found
224
+ #
225
+ # @example Verify email field renders
226
+ # get "/admin/users/new"
227
+ # assert_admin_form_field("email")
228
+ #
229
+ # @example Verify name field renders
230
+ # get "/admin/users/1/edit"
231
+ # assert_admin_form_field(:name)
232
+ def assert_admin_form_field(key)
233
+ assert_select "[name*='[#{key}]']",
234
+ message: "Expected form field '#{key}'"
235
+ end
236
+
237
+ # Assert that page tabs are rendered for a static page.
238
+ #
239
+ # This method verifies that page action tabs with the given labels are rendered
240
+ # in the current response body. It performs a simple text match for each label,
241
+ # which works because page tabs render their labels as plain text within tab links.
242
+ # Useful for verifying that registered page actions appear as tabs.
243
+ #
244
+ # @param labels [Array<String>] one or more page tab labels to look for
245
+ #
246
+ # @return [void]
247
+ #
248
+ # @raise [Minitest::Assertion] if any page tab label is not found
249
+ #
250
+ # @example Verify analytics page tabs
251
+ # get "/admin/analytics"
252
+ # assert_admin_page_tabs("Overview", "By Metric")
253
+ #
254
+ # @example Verify settings page tabs
255
+ # get "/admin/settings"
256
+ # assert_admin_page_tabs("General", "Security", "Billing")
257
+ def assert_admin_page_tabs(*labels)
258
+ labels.each do |label|
259
+ assert_match label, response.body,
260
+ "Expected page tab '#{label}' in response"
261
+ end
262
+ end
263
+
264
+ # Assert that the current theme CSS is loaded in the layout.
265
+ #
266
+ # This method verifies that the stylesheet link tag for the specified theme
267
+ # is present in the current response. It first checks that the theme is registered,
268
+ # then uses `assert_select` to verify the CSS link tag exists with the theme's
269
+ # CSS path in its href attribute. Useful for verifying theme application.
270
+ #
271
+ # @param theme_key [String, Symbol] the theme key to verify (e.g., :default, :modern)
272
+ #
273
+ # @return [void]
274
+ #
275
+ # @raise [Minitest::Assertion] if the theme is not registered or CSS link is not found
276
+ #
277
+ # @example Verify default theme is loaded
278
+ # get "/admin/dashboard"
279
+ # assert_admin_theme(:default)
280
+ #
281
+ # @example Verify modern theme is loaded
282
+ # # After configuring: RSB::Admin.configuration.theme = :modern
283
+ # get "/admin/dashboard"
284
+ # assert_admin_theme(:modern)
285
+ def assert_admin_theme(theme_key)
286
+ theme = RSB::Admin.themes[theme_key.to_sym]
287
+ assert theme, "Theme '#{theme_key}' not registered"
288
+ assert_select "link[href*='#{theme.css}']",
289
+ message: "Expected theme CSS '#{theme.css}' in layout"
290
+ end
291
+
292
+ # Assert that a custom dashboard page is registered with the given controller.
293
+ #
294
+ # Verifies that `RSB::Admin.registry.dashboard_page` is present and
295
+ # its controller matches the expected value. Useful for extension gems
296
+ # that register a custom dashboard to verify their registration works.
297
+ #
298
+ # @param controller [String] the expected controller path
299
+ #
300
+ # @return [void]
301
+ #
302
+ # @raise [Minitest::Assertion] if no dashboard override or wrong controller
303
+ #
304
+ # @example Verify dashboard override
305
+ # RSB::Admin.registry.register_dashboard(controller: "admin/dashboard")
306
+ # assert_admin_dashboard_override(controller: "admin/dashboard")
307
+ def assert_admin_dashboard_override(controller:)
308
+ page = RSB::Admin.registry.dashboard_page
309
+ assert page, 'Expected a dashboard override to be registered'
310
+ assert_equal controller, page.controller,
311
+ "Expected dashboard controller '#{controller}', got '#{page.controller}'"
312
+ end
313
+ end
314
+ end
315
+ end
316
+ end
@@ -0,0 +1,193 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSB
4
+ module Admin
5
+ module TestKit
6
+ class ResourceTestCase < ActionDispatch::IntegrationTest
7
+ include RSB::Admin::TestKit::Helpers
8
+
9
+ class_attribute :resource_class
10
+ class_attribute :category
11
+ class_attribute :record_factory
12
+ class_attribute :resource_registry_block
13
+
14
+ setup do
15
+ # Re-register the resource if a registry block is provided
16
+ self.class.resource_registry_block&.call
17
+ @superadmin = create_test_admin!(superadmin: true)
18
+ @registration = RSB::Admin.registry.find_resource(resource_class) if resource_class
19
+ end
20
+
21
+ # Define how to register the resource (called in setup after reset)
22
+ def self.registers_in_admin(&block)
23
+ self.resource_registry_block = block
24
+ end
25
+
26
+ def test_resource_is_registered_in_admin_registry
27
+ skip 'no resource_class configured' unless resource_class
28
+ assert @registration, "#{resource_class} not found in admin registry"
29
+ assert_equal category, @registration.category_name
30
+ end
31
+
32
+ def test_index_page_renders
33
+ skip 'no resource_class configured' unless resource_class
34
+ skip 'no :index action registered' unless @registration&.action?(:index)
35
+ record_factory&.call
36
+ sign_in_admin(@superadmin)
37
+ get admin_resources_path
38
+ assert_admin_authorized
39
+ end
40
+
41
+ def test_show_page_renders
42
+ skip 'no resource_class configured' unless resource_class
43
+ skip 'no :show action registered' unless @registration&.action?(:show)
44
+ record = record_factory&.call
45
+ skip 'no record_factory configured' unless record
46
+ sign_in_admin(@superadmin)
47
+ get admin_resource_path(record)
48
+ assert_admin_authorized
49
+ end
50
+
51
+ def test_admin_with_no_permissions_is_denied
52
+ skip 'no resource_class configured' unless resource_class
53
+ skip 'no :index action registered' unless @registration&.action?(:index)
54
+ restricted = create_test_admin!(permissions: {})
55
+ sign_in_admin(restricted)
56
+ get admin_resources_path
57
+ assert_admin_denied
58
+ end
59
+
60
+ # Contract test: Verify that registered columns appear in the index table.
61
+ #
62
+ # This test ensures that all columns defined for the resource via the DSL
63
+ # (or auto-detected from the model schema) are rendered as table headers
64
+ # in the index view. It creates a test record, signs in as superadmin,
65
+ # visits the index page, and asserts that each column's label appears
66
+ # in a `<th>` element. Skips if no records exist (table won't render).
67
+ #
68
+ # For auto-detected columns, assertions are more forgiving since some
69
+ # columns may be filtered out by the view (e.g., SKIP_INDEX_COLUMNS).
70
+ # For explicitly-defined columns, all columns must render.
71
+ #
72
+ # @return [void]
73
+ #
74
+ # @raise [Minitest::Skip] if resource_class is not configured, :index action not registered, or no records exist
75
+ def test_index_page_renders_registered_columns
76
+ skip 'no resource_class configured' unless resource_class
77
+ skip 'no :index action' unless @registration&.action?(:index)
78
+ record = record_factory&.call
79
+ skip 'no record_factory configured or record creation failed' unless record&.persisted?
80
+ sign_in_admin(@superadmin)
81
+ get admin_resources_path
82
+ assert_admin_authorized
83
+
84
+ # Verify table renders (requires at least one th element)
85
+ begin
86
+ assert_select 'th', minimum: 1
87
+ rescue Minitest::Assertion
88
+ skip 'table not rendered (no records found in view)'
89
+ end
90
+
91
+ # Check columns - be forgiving with auto-detected columns
92
+ auto_detected = @registration.columns.nil?
93
+ @registration.index_columns.each do |col|
94
+ next if col.label.blank?
95
+
96
+ if auto_detected
97
+ # For auto-detected columns, silently skip if column doesn't render
98
+ # (it may be filtered by view logic we don't control)
99
+ begin
100
+ assert_admin_column_rendered(col.label)
101
+ rescue Minitest::Assertion
102
+ # Skip this column
103
+ next
104
+ end
105
+ else
106
+ # For explicitly-defined columns, all must render
107
+ assert_admin_column_rendered(col.label)
108
+ end
109
+ end
110
+ end
111
+
112
+ # Contract test: Verify that registered filters appear in the index view.
113
+ #
114
+ # This test ensures that all filters defined for the resource via the DSL
115
+ # are rendered as form inputs in the index view. It signs in as superadmin,
116
+ # visits the index page, and asserts that each filter's input element exists
117
+ # with the correct name attribute format `q[key]`.
118
+ #
119
+ # @return [void]
120
+ #
121
+ # @raise [Minitest::Skip] if resource_class is not configured or no filters defined
122
+ def test_index_page_renders_registered_filters
123
+ skip 'no resource_class configured' unless resource_class
124
+ skip 'no filters' unless @registration&.filters&.any?
125
+ sign_in_admin(@superadmin)
126
+ get admin_resources_path
127
+ assert_admin_authorized
128
+ @registration.filters.each do |filter|
129
+ assert_admin_filter_rendered(filter.key)
130
+ end
131
+ end
132
+
133
+ # Contract test: Verify that registered form fields appear in the new form.
134
+ #
135
+ # This test ensures that all form fields defined for the resource via the DSL
136
+ # (or auto-detected from the model schema) are rendered as form inputs in the
137
+ # new view. It signs in as superadmin, visits the new page, and asserts that
138
+ # each field's input element exists with the correct name attribute.
139
+ #
140
+ # @return [void]
141
+ #
142
+ # @raise [Minitest::Skip] if resource_class is not configured, :new action not registered, or no form_fields defined
143
+ def test_new_page_renders_registered_form_fields
144
+ skip 'no resource_class configured' unless resource_class
145
+ skip 'no :new action' unless @registration&.action?(:new)
146
+ skip 'no form_fields' unless @registration&.form_fields&.any?
147
+ sign_in_admin(@superadmin)
148
+ get "#{admin_resources_path}/new"
149
+ assert_admin_authorized
150
+ @registration.new_form_fields.each do |field|
151
+ assert_admin_form_field(field.key)
152
+ end
153
+ end
154
+
155
+ # Contract test: Verify that breadcrumbs are rendered in resource views.
156
+ #
157
+ # This test ensures that the breadcrumb navigation is present in resource views,
158
+ # including at minimum the "Dashboard" home breadcrumb. It signs in as superadmin,
159
+ # visits the index page, and asserts that the dashboard breadcrumb appears.
160
+ #
161
+ # @return [void]
162
+ #
163
+ # @raise [Minitest::Skip] if resource_class is not configured or :index action not registered
164
+ def test_breadcrumbs_are_rendered
165
+ skip 'no resource_class configured' unless resource_class
166
+ skip 'no :index action' unless @registration&.action?(:index)
167
+ sign_in_admin(@superadmin)
168
+ get admin_resources_path
169
+ assert_admin_authorized
170
+ assert_admin_breadcrumbs('Dashboard')
171
+ end
172
+
173
+ private
174
+
175
+ def create_resource(**overrides)
176
+ record_factory&.call(**overrides)
177
+ end
178
+
179
+ def admin_resources_path
180
+ rsb_admin.send(:"#{resource_class.model_name.route_key}_path")
181
+ rescue NoMethodError
182
+ "/admin/#{resource_class.model_name.route_key}"
183
+ end
184
+
185
+ def admin_resource_path(record)
186
+ rsb_admin.send(:"#{resource_class.model_name.singular_route_key}_path", record)
187
+ rescue NoMethodError
188
+ "/admin/#{resource_class.model_name.route_key}/#{record.id}"
189
+ end
190
+ end
191
+ end
192
+ end
193
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rsb/admin/test_kit/helpers'
4
+ require 'rsb/admin/test_kit/resource_test_case'
5
+
6
+ module RSB
7
+ module Admin
8
+ module TestKit
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSB
4
+ module Admin
5
+ # Represents a theme definition for the admin panel.
6
+ #
7
+ # ThemeDefinition is an immutable data structure that describes a visual theme
8
+ # for the admin interface, including CSS, JavaScript, and optional view overrides.
9
+ #
10
+ # @!attribute [r] key
11
+ # @return [Symbol] unique theme identifier (e.g., :default, :modern)
12
+ # @!attribute [r] label
13
+ # @return [String] the human-readable theme name
14
+ # @!attribute [r] css
15
+ # @return [String] the asset path for the theme's CSS file
16
+ # @!attribute [r] js
17
+ # @return [String, nil] optional asset path for the theme's JavaScript file
18
+ # @!attribute [r] views_path
19
+ # @return [String, nil] optional view override path prefix
20
+ #
21
+ # @example Building a minimal theme
22
+ # theme = ThemeDefinition.new(
23
+ # key: :default,
24
+ # label: "Default Theme",
25
+ # css: "rsb/admin/themes/default",
26
+ # js: nil,
27
+ # views_path: nil
28
+ # )
29
+ #
30
+ # @example Building a full-featured theme
31
+ # theme = ThemeDefinition.new(
32
+ # key: :modern,
33
+ # label: "Modern Theme",
34
+ # css: "rsb/admin/themes/modern",
35
+ # js: "rsb/admin/themes/modern",
36
+ # views_path: "rsb/admin/themes/modern/views"
37
+ # )
38
+ ThemeDefinition = Data.define(
39
+ :key, # Symbol — :default, :modern, or custom
40
+ :label, # String
41
+ :css, # String — asset path
42
+ :js, # String | nil — asset path for optional JS
43
+ :views_path # String | nil — view override path prefix
44
+ )
45
+ end
46
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSB
4
+ module Admin
5
+ module Themes
6
+ # Self-contained registration for the Modern theme.
7
+ #
8
+ # This module encapsulates all registration logic for the built-in Modern
9
+ # theme. It exists as a clear boundary so the modern theme can eventually
10
+ # be extracted to a separate gem (`rsb-admin-modern-theme`).
11
+ #
12
+ # To extract to a separate gem:
13
+ # 1. Move this module + assets + views to rsb-admin-modern-theme gem
14
+ # 2. Replace the call in RSB::Admin.register_built_in_themes with a
15
+ # dependency on the new gem
16
+ # 3. The gem's engine calls RSB::Admin::Themes::Modern.register! in
17
+ # an initializer
18
+ #
19
+ # @example Register the modern theme
20
+ # RSB::Admin::Themes::Modern.register!
21
+ module Modern
22
+ # Registers the Modern theme with RSB::Admin.
23
+ #
24
+ # This method registers the theme definition including CSS (with dark
25
+ # mode support), JavaScript (toggle + persistence), and view overrides
26
+ # (sidebar, header).
27
+ #
28
+ # @return [RSB::Admin::ThemeDefinition] the registered theme definition
29
+ #
30
+ # @example
31
+ # RSB::Admin::Themes::Modern.register!
32
+ # RSB::Admin.themes[:modern]
33
+ # # => #<data RSB::Admin::ThemeDefinition key=:modern, ...>
34
+ def self.register!
35
+ RSB::Admin.register_theme :modern,
36
+ label: 'Modern',
37
+ css: 'rsb/admin/themes/modern',
38
+ js: 'rsb/admin/themes/modern',
39
+ views_path: 'rsb/admin/themes/modern/views'
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../../../../lib/rsb/version' unless defined?(RSB::VERSION)
4
+
5
+ module RSB
6
+ module Admin
7
+ VERSION = RSB::VERSION
8
+ end
9
+ end