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,218 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSB
4
+ module Admin
5
+ # Rails generator for scaffolding custom admin themes.
6
+ #
7
+ # This generator creates either a host-app theme (default) or a complete
8
+ # Rails engine gem for distributable themes. Both modes include:
9
+ # - CSS with all required `--rsb-admin-*` variables
10
+ # - View overrides (sidebar and header)
11
+ # - Theme registration code
12
+ #
13
+ # @example Generate a host-app theme
14
+ # rails generate rsb:admin:theme corporate
15
+ #
16
+ # @example Generate a theme as a Rails engine gem
17
+ # rails generate rsb:admin:theme corporate --engine
18
+ class ThemeGenerator < Rails::Generators::NamedBase
19
+ namespace 'rsb:admin:theme'
20
+ source_root File.expand_path('templates', __dir__)
21
+
22
+ desc 'Scaffold a new RSB Admin theme.'
23
+
24
+ class_option :engine, type: :boolean, default: false,
25
+ desc: 'Generate as a Rails engine gem (for distribution)'
26
+
27
+ # Creates the theme scaffold based on the `--engine` option.
28
+ #
29
+ # Dispatches to either {#create_host_app_scaffold} for host-app themes
30
+ # or {#create_engine_scaffold} for engine gems.
31
+ #
32
+ # @return [void]
33
+ def create_theme
34
+ if options[:engine]
35
+ create_engine_scaffold
36
+ else
37
+ create_host_app_scaffold
38
+ end
39
+ end
40
+
41
+ # Prints post-generation instructions to the console.
42
+ #
43
+ # Instructions differ based on whether a host-app or engine scaffold
44
+ # was generated.
45
+ #
46
+ # @return [void]
47
+ def print_instructions
48
+ say ''
49
+ if options[:engine]
50
+ say "Theme engine scaffold created at #{engine_dir}/", :green
51
+ say ''
52
+ say 'Next steps:'
53
+ say " 1. cd #{engine_dir}"
54
+ say " 2. Customize the CSS variables in app/assets/stylesheets/rsb/admin/themes/#{file_name}.css"
55
+ say " 3. Customize views in app/views/rsb/admin/themes/#{file_name}/views/"
56
+ say ' 4. Build: bundle exec rake build'
57
+ say " 5. Add to host app Gemfile: gem 'rsb-admin-#{file_name}-theme', path: '#{engine_dir}'"
58
+ else
59
+ say 'Theme scaffold created!', :green
60
+ say ''
61
+ say 'Next steps:'
62
+ say " 1. Customize CSS variables in app/assets/stylesheets/admin/themes/#{file_name}.css"
63
+ say " 2. Customize views in app/views/admin/themes/#{file_name}/views/"
64
+ say " 3. Activate via Settings page or: RSB::Admin.configure { |c| c.theme = :#{file_name} }"
65
+ end
66
+ say ''
67
+ end
68
+
69
+ private
70
+
71
+ # ── Host App Scaffold ──────────────────────────
72
+
73
+ # Creates a host-app theme scaffold with CSS, views, and an initializer.
74
+ #
75
+ # Files created:
76
+ # - `app/assets/stylesheets/admin/themes/{name}.css`
77
+ # - `app/views/admin/themes/{name}/views/shared/_sidebar.html.erb`
78
+ # - `app/views/admin/themes/{name}/views/shared/_header.html.erb`
79
+ # - `config/initializers/rsb_admin_{name}_theme.rb`
80
+ #
81
+ # @return [void]
82
+ # @api private
83
+ def create_host_app_scaffold
84
+ # CSS with all variables
85
+ template 'theme.css.tt',
86
+ "app/assets/stylesheets/admin/themes/#{file_name}.css"
87
+
88
+ # Copy default sidebar and header as starting points
89
+ copy_engine_view('shared/_sidebar.html.erb',
90
+ "app/views/admin/themes/#{file_name}/views/shared/_sidebar.html.erb")
91
+ copy_engine_view('shared/_header.html.erb',
92
+ "app/views/admin/themes/#{file_name}/views/shared/_header.html.erb")
93
+
94
+ # Initializer to register the theme
95
+ create_file "config/initializers/rsb_admin_#{file_name}_theme.rb", <<~RUBY
96
+ RSB::Admin.register_theme :#{file_name},
97
+ label: "#{class_name}",
98
+ css: "admin/themes/#{file_name}",
99
+ views_path: "admin/themes/#{file_name}/views"
100
+ RUBY
101
+ end
102
+
103
+ # ── Engine Scaffold ────────────────────────────
104
+
105
+ # Creates a complete Rails engine gem scaffold for the theme.
106
+ #
107
+ # Creates a directory structure with:
108
+ # - Gemspec with `rsb-admin` dependency
109
+ # - Engine class with theme registration
110
+ # - CSS and view assets
111
+ # - Gemfile and Rakefile
112
+ #
113
+ # @return [void]
114
+ # @api private
115
+ def create_engine_scaffold
116
+ # Gemspec
117
+ create_file "#{engine_dir}/rsb-admin-#{file_name}-theme.gemspec", <<~GEMSPEC
118
+ Gem::Specification.new do |s|
119
+ s.name = "rsb-admin-#{file_name}-theme"
120
+ s.version = "0.1.0"
121
+ s.summary = "#{class_name} theme for RSB Admin"
122
+ s.description = "A custom theme for the RSB Admin panel."
123
+ s.license = "MIT"
124
+ s.authors = ["TODO: Your name"]
125
+
126
+ s.files = Dir["{app,lib}/**/*", "LICENSE", "README.md"]
127
+ s.require_paths = ["lib"]
128
+
129
+ s.add_dependency "rsb-admin"
130
+ end
131
+ GEMSPEC
132
+
133
+ # Main lib file
134
+ create_file "#{engine_dir}/lib/rsb/admin/#{file_name}_theme.rb", <<~RUBY
135
+ require "rsb/admin/#{file_name}_theme/engine"
136
+ RUBY
137
+
138
+ # Engine
139
+ create_file "#{engine_dir}/lib/rsb/admin/#{file_name}_theme/engine.rb", <<~RUBY
140
+ module RSB
141
+ module Admin
142
+ module #{class_name}Theme
143
+ class Engine < ::Rails::Engine
144
+ isolate_namespace RSB::Admin::#{class_name}Theme
145
+
146
+ initializer "rsb_admin_#{file_name}_theme.register" do
147
+ RSB::Admin.register_theme :#{file_name},
148
+ label: "#{class_name}",
149
+ css: "rsb/admin/themes/#{file_name}",
150
+ views_path: "rsb/admin/themes/#{file_name}/views"
151
+ end
152
+ end
153
+ end
154
+ end
155
+ end
156
+ RUBY
157
+
158
+ # CSS with all variables
159
+ template 'theme.css.tt',
160
+ "#{engine_dir}/app/assets/stylesheets/rsb/admin/themes/#{file_name}.css"
161
+
162
+ # Copy default sidebar and header as starting points
163
+ copy_engine_view('shared/_sidebar.html.erb',
164
+ "#{engine_dir}/app/views/rsb/admin/themes/#{file_name}/views/shared/_sidebar.html.erb")
165
+ copy_engine_view('shared/_header.html.erb',
166
+ "#{engine_dir}/app/views/rsb/admin/themes/#{file_name}/views/shared/_header.html.erb")
167
+
168
+ # Gemfile
169
+ create_file "#{engine_dir}/Gemfile", <<~GEMFILE
170
+ source "https://rubygems.org"
171
+
172
+ gemspec
173
+
174
+ gem "rsb-admin", path: "../../"
175
+ GEMFILE
176
+
177
+ # Rakefile
178
+ create_file "#{engine_dir}/Rakefile", <<~RAKE
179
+ require "bundler/gem_tasks"
180
+ RAKE
181
+ end
182
+
183
+ # Returns the directory name for the engine gem.
184
+ #
185
+ # @return [String] the engine directory name
186
+ # @api private
187
+ def engine_dir
188
+ "rsb-admin-#{file_name}-theme"
189
+ end
190
+
191
+ # Returns the theme name (same as file_name).
192
+ #
193
+ # @return [String] the theme name
194
+ # @api private
195
+ def theme_name
196
+ file_name
197
+ end
198
+
199
+ # Copies a view file from the RSB Admin engine to a destination path.
200
+ #
201
+ # If the source view doesn't exist in the engine, prints a skip message
202
+ # instead of failing.
203
+ #
204
+ # @param relative_path [String] the view path relative to `rsb/admin/`
205
+ # @param destination [String] the destination path for the copied view
206
+ # @return [void]
207
+ # @api private
208
+ def copy_engine_view(relative_path, destination)
209
+ source = RSB::Admin::Engine.root.join('app', 'views', 'rsb', 'admin', relative_path)
210
+ if File.exist?(source)
211
+ create_file destination, File.read(source)
212
+ else
213
+ say_status :skip, "#{relative_path} (source not found)", :yellow
214
+ end
215
+ end
216
+ end
217
+ end
218
+ end
@@ -0,0 +1,262 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSB
4
+ module Admin
5
+ # Generator to export RSB Admin views to the host application for customization.
6
+ #
7
+ # This generator copies view files from the RSB Admin engine (or a specific theme)
8
+ # to the host application's view override path. It supports selective export via
9
+ # `--only` group filtering and theme-specific views via `--theme`.
10
+ #
11
+ # @example Export all views
12
+ # rails generate rsb:admin:views
13
+ #
14
+ # @example Export only sidebar and header
15
+ # rails generate rsb:admin:views --only sidebar,header
16
+ #
17
+ # @example Export modern theme views
18
+ # rails generate rsb:admin:views --theme modern
19
+ #
20
+ # @example Force overwrite existing files
21
+ # rails generate rsb:admin:views --force
22
+ class ViewsGenerator < Rails::Generators::Base
23
+ namespace 'rsb:admin:views'
24
+ desc 'Export RSB Admin views to your application for customization.'
25
+
26
+ class_option :only, type: :string, default: nil,
27
+ desc: 'Comma-separated list of view groups to export (sidebar,header,layout,breadcrumbs,resources,fields,dashboard,sessions)'
28
+ class_option :theme, type: :string, default: nil,
29
+ desc: 'Export views for a specific theme (e.g., modern)'
30
+ class_option :force, type: :boolean, default: false,
31
+ desc: 'Overwrite existing files'
32
+
33
+ # Mapping of view groups to their file paths (relative to the views root).
34
+ # Paths use the partial naming convention (with underscore prefix for partials).
35
+ VIEW_GROUPS = {
36
+ 'sidebar' => ['shared/_sidebar.html.erb'],
37
+ 'header' => ['shared/_header.html.erb'],
38
+ 'layout' => ['layouts/rsb/admin/application.html.erb'],
39
+ 'breadcrumbs' => ['shared/_breadcrumbs.html.erb'],
40
+ 'resources' => [
41
+ 'resources/index.html.erb',
42
+ 'resources/show.html.erb',
43
+ 'resources/new.html.erb',
44
+ 'resources/edit.html.erb',
45
+ 'resources/_table.html.erb',
46
+ 'resources/_filters.html.erb',
47
+ 'resources/_form.html.erb',
48
+ 'resources/_pagination.html.erb'
49
+ ],
50
+ 'fields' => [
51
+ 'shared/fields/_text.html.erb',
52
+ 'shared/fields/_textarea.html.erb',
53
+ 'shared/fields/_select.html.erb',
54
+ 'shared/fields/_checkbox.html.erb',
55
+ 'shared/fields/_number.html.erb',
56
+ 'shared/fields/_email.html.erb',
57
+ 'shared/fields/_password.html.erb',
58
+ 'shared/fields/_datetime.html.erb',
59
+ 'shared/fields/_json.html.erb'
60
+ ],
61
+ 'dashboard' => ['dashboard/index.html.erb'],
62
+ 'sessions' => ['sessions/new.html.erb']
63
+ }.freeze
64
+
65
+ # Copies the selected view files from the engine to the host application.
66
+ #
67
+ # This is the main action that exports views, handling group filtering,
68
+ # theme selection, and skip/force logic for existing files.
69
+ #
70
+ # @return [void]
71
+ def copy_views
72
+ groups = selected_groups
73
+ source_root = resolve_source_root
74
+
75
+ unless File.directory?(source_root)
76
+ say_status :error, "Source directory not found: #{source_root}", :red
77
+ return
78
+ end
79
+
80
+ files_copied = 0
81
+ files_skipped = 0
82
+
83
+ groups.each_value do |paths|
84
+ paths.each do |relative_path|
85
+ source = source_path_for(relative_path)
86
+ destination = File.join(target_directory, relative_path)
87
+
88
+ unless File.exist?(source)
89
+ say_status :skip, "#{relative_path} (not found in source)", :yellow
90
+ next
91
+ end
92
+
93
+ if File.exist?(destination) && !options[:force]
94
+ say_status :skip, "#{relative_path} (already exists, use --force to overwrite)", :yellow
95
+ files_skipped += 1
96
+ else
97
+ copy_file_with_status(source, destination)
98
+ files_copied += 1
99
+ end
100
+ end
101
+ end
102
+
103
+ say ''
104
+ say "Exported #{files_copied} view(s) to #{target_directory}/", :green
105
+ say "Skipped #{files_skipped} existing file(s)." if files_skipped.positive?
106
+ end
107
+
108
+ # Sets up the view_overrides_path configuration if not already configured.
109
+ #
110
+ # Creates an initializer that configures the override path when the
111
+ # configuration is currently nil. This ensures views exported to the
112
+ # host app will be found by the engine.
113
+ #
114
+ # @return [void]
115
+ def setup_override_path
116
+ return if RSB::Admin.configuration.view_overrides_path.present?
117
+
118
+ override_path = 'rsb_admin_overrides'
119
+ initializer_path = 'config/initializers/rsb_admin_views.rb'
120
+
121
+ if File.exist?(File.join(destination_root, initializer_path))
122
+ say_status :skip, initializer_path, :yellow
123
+ return
124
+ end
125
+
126
+ create_file initializer_path, <<~RUBY
127
+ RSB::Admin.configure do |config|
128
+ config.view_overrides_path = "#{override_path}"
129
+ end
130
+ RUBY
131
+
132
+ say ''
133
+ say "Created initializer to set view_overrides_path = \"#{override_path}\"", :green
134
+ end
135
+
136
+ # Prints instructions about the view override chain.
137
+ #
138
+ # Explains how the Rails view resolution works with the exported files,
139
+ # helping developers understand the priority order.
140
+ #
141
+ # @return [void]
142
+ def print_instructions
143
+ say ''
144
+ say 'View override chain (highest priority first):', :cyan
145
+ say " 1. app/views/#{override_path_name}/ (your customizations)"
146
+ say ' 2. Theme views (if active theme has view overrides)'
147
+ say ' 3. RSB Admin engine defaults (fallback)'
148
+ say ''
149
+ say 'Edit the exported files to customize your admin panel.'
150
+ say "Files you didn't export will continue using engine defaults."
151
+ say ''
152
+ end
153
+
154
+ private
155
+
156
+ # Returns the filtered set of view groups based on the `--only` option.
157
+ #
158
+ # If `--only` is not specified, returns all groups. If specified, validates
159
+ # group names and exits with an error message if any invalid groups are found.
160
+ #
161
+ # @return [Hash{String => Array<String>}] filtered view groups hash
162
+ # @raise [SystemExit] if invalid group names are provided
163
+ def selected_groups
164
+ if options[:only]
165
+ keys = options[:only].split(',').map(&:strip)
166
+ invalid = keys - VIEW_GROUPS.keys
167
+ if invalid.any?
168
+ say_status :error,
169
+ "Unknown view group(s): #{invalid.join(', ')}. Valid groups: #{VIEW_GROUPS.keys.join(', ')}", :red
170
+ exit 1
171
+ end
172
+ VIEW_GROUPS.slice(*keys)
173
+ else
174
+ VIEW_GROUPS
175
+ end
176
+ end
177
+
178
+ # Resolves the source root directory based on the `--theme` option.
179
+ #
180
+ # If a theme is specified, uses the theme's views_path if available,
181
+ # otherwise falls back to engine defaults. If no theme is specified,
182
+ # uses the engine's default view directory.
183
+ #
184
+ # @return [String] absolute path to the source view directory
185
+ # @raise [SystemExit] if an invalid theme name is provided
186
+ def resolve_source_root
187
+ if options[:theme]
188
+ theme_key = options[:theme].to_sym
189
+ theme = RSB::Admin.themes[theme_key]
190
+ if theme.nil?
191
+ say_status :error, "Unknown theme: #{options[:theme]}. Available: #{RSB::Admin.themes.keys.join(', ')}",
192
+ :red
193
+ exit 1
194
+ end
195
+
196
+ if theme.views_path
197
+ # Theme has custom views — use them as source
198
+ RSB::Admin::Engine.root.join('app', 'views', theme.views_path).to_s
199
+ else
200
+ # Theme has no custom views — fall back to engine defaults
201
+ engine_views_root
202
+ end
203
+ else
204
+ engine_views_root
205
+ end
206
+ end
207
+
208
+ # Returns the full source path for a given relative view path.
209
+ #
210
+ # Handles the special case of layout files, which live under `app/views/layouts/`
211
+ # instead of the namespaced view directory.
212
+ #
213
+ # @param relative_path [String] the relative path from VIEW_GROUPS
214
+ # @return [String] absolute path to the source file
215
+ def source_path_for(relative_path)
216
+ if relative_path.start_with?('layouts/')
217
+ RSB::Admin::Engine.root.join('app', 'views', relative_path).to_s
218
+ else
219
+ File.join(resolve_source_root, relative_path)
220
+ end
221
+ end
222
+
223
+ # Returns the engine's default namespaced views root directory.
224
+ #
225
+ # @return [String] absolute path to rsb/admin view directory in the engine
226
+ def engine_views_root
227
+ RSB::Admin::Engine.root.join('app', 'views', 'rsb', 'admin').to_s
228
+ end
229
+
230
+ # Returns the target directory where views will be exported.
231
+ #
232
+ # Uses the configured view_overrides_path or the default "rsb_admin_overrides".
233
+ #
234
+ # @return [String] absolute path to the target view directory
235
+ def target_directory
236
+ File.join(destination_root, 'app', 'views', override_path_name)
237
+ end
238
+
239
+ # Returns the configured or default override path name.
240
+ #
241
+ # @return [String] the view override path name
242
+ def override_path_name
243
+ RSB::Admin.configuration.view_overrides_path || 'rsb_admin_overrides'
244
+ end
245
+
246
+ # Copies a file and prints a status message.
247
+ #
248
+ # Creates parent directories if needed and reports the relative path
249
+ # that was created.
250
+ #
251
+ # @param source [String] absolute path to source file
252
+ # @param destination [String] absolute path to destination file
253
+ # @return [void]
254
+ def copy_file_with_status(source, destination)
255
+ FileUtils.mkdir_p(File.dirname(destination))
256
+ FileUtils.cp(source, destination)
257
+ relative = destination.sub("#{destination_root}/", '')
258
+ say_status :create, relative, :green
259
+ end
260
+ end
261
+ end
262
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSB
4
+ module Admin
5
+ # Represents a breadcrumb item in admin navigation.
6
+ #
7
+ # BreadcrumbItem is an immutable data structure representing a single item
8
+ # in a breadcrumb trail. The path is nil for the current (last) item.
9
+ #
10
+ # @!attribute [r] label
11
+ # @return [String] the text to display for this breadcrumb
12
+ # @!attribute [r] path
13
+ # @return [String, nil] the URL path (nil for the current/last item)
14
+ #
15
+ # @example Building a breadcrumb trail
16
+ # [
17
+ # BreadcrumbItem.new(label: "Admin", path: "/admin"),
18
+ # BreadcrumbItem.new(label: "Users", path: "/admin/users"),
19
+ # BreadcrumbItem.new(label: "John Doe", path: nil) # current page
20
+ # ]
21
+ BreadcrumbItem = Data.define(
22
+ :label, # String
23
+ :path # String | nil — nil for current (last) item
24
+ )
25
+ end
26
+ end
@@ -0,0 +1,177 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSB
4
+ module Admin
5
+ # Represents a category of admin resources and pages.
6
+ #
7
+ # Categories group related resources and pages in the admin interface sidebar.
8
+ # Each category has a name and contains collections of resources (CRUD interfaces
9
+ # for models) and pages (custom non-CRUD interfaces).
10
+ #
11
+ # @!attribute [r] name
12
+ # @return [String] the category name
13
+ #
14
+ # @!attribute [r] resources
15
+ # @return [Array<ResourceRegistration>] registered resources in this category
16
+ #
17
+ # @!attribute [r] pages
18
+ # @return [Array<PageRegistration>] registered pages in this category
19
+ #
20
+ # @example Creating a category with resources and pages
21
+ # category = CategoryRegistration.new("Users")
22
+ # category.resource User, icon: "users", actions: [:index, :show]
23
+ # category.page :dashboard, label: "Dashboard", controller: "admin/dashboard"
24
+ #
25
+ # @see Registry#register_category
26
+ # @see ResourceRegistration
27
+ # @see PageRegistration
28
+ class CategoryRegistration
29
+ attr_reader :name, :resources, :pages
30
+
31
+ # Initialize a new category with an empty set of resources and pages.
32
+ #
33
+ # @param name [String] the category name
34
+ #
35
+ # @example
36
+ # category = CategoryRegistration.new("Authentication")
37
+ def initialize(name)
38
+ @name = name
39
+ @resources = []
40
+ @pages = []
41
+ end
42
+
43
+ # Register a resource (ActiveRecord model) in this category.
44
+ #
45
+ # Resources can be registered with or without a configuration block.
46
+ # When a block is provided, it runs in the context of {ResourceDSLContext}
47
+ # and allows you to define columns, filters, and form fields inline.
48
+ #
49
+ # @param model_class [Class] the ActiveRecord model class to register
50
+ # @param icon [String, nil] optional icon identifier for the resource
51
+ # @param label [String, nil] optional custom label (defaults to humanized plural model name)
52
+ # @param actions [Array<Symbol>] allowed actions for this resource (default: `[:index, :show]`)
53
+ # @param controller [String, nil] optional custom controller path for delegation
54
+ # @param per_page [Integer, nil] number of records per page for index
55
+ # @param default_sort [Hash, nil] default sort configuration (e.g., `{ column: :created_at, direction: :desc }`)
56
+ # @param search_fields [Array<Symbol>, nil] searchable field names
57
+ # @param options [Hash] additional options passed to the registration
58
+ # @yield [ResourceDSLContext] optional block for defining columns, filters, and form fields
59
+ #
60
+ # @return [void]
61
+ #
62
+ # @example Basic resource registration
63
+ # resource User, icon: "users", actions: [:index, :show]
64
+ #
65
+ # @example Resource with inline DSL
66
+ # resource User, icon: "users", actions: [:index, :show, :edit, :update] do
67
+ # column :id, link: true
68
+ # column :email, sortable: true
69
+ # filter :email, type: :text
70
+ # form_field :email, type: :email, required: true
71
+ # end
72
+ #
73
+ # @example Resource with custom controller
74
+ # resource Identity, controller: "admin/identities", actions: [:index, :show, :suspend]
75
+ #
76
+ # @see ResourceDSLContext
77
+ # @see ResourceRegistration
78
+ def resource(model_class, icon: nil, label: nil, actions: %i[index show], controller: nil,
79
+ per_page: nil, default_sort: nil, search_fields: nil,
80
+ **options, &block)
81
+ dsl = nil
82
+ if block
83
+ dsl = ResourceDSLContext.new
84
+ dsl.instance_eval(&block)
85
+ end
86
+
87
+ @resources << ResourceRegistration.new(
88
+ model_class: model_class,
89
+ category_name: @name,
90
+ icon: icon,
91
+ label: label || model_class.model_name.human.pluralize,
92
+ actions: actions,
93
+ controller: controller,
94
+ columns: dsl&.columns&.presence,
95
+ filters: dsl&.filters&.presence,
96
+ form_fields: dsl&.form_fields&.presence,
97
+ per_page: per_page,
98
+ default_sort: default_sort,
99
+ search_fields: search_fields,
100
+ **options
101
+ )
102
+ end
103
+
104
+ # Register a custom page in this category.
105
+ #
106
+ # Pages are non-CRUD admin interfaces that require custom controllers.
107
+ # Unlike resources, pages don't map to a specific model.
108
+ #
109
+ # @param key [Symbol, String] unique identifier for this page
110
+ # @param label [String] the human-readable page name
111
+ # @param icon [String, nil] optional icon identifier
112
+ # @param controller [String] the controller path (e.g., "admin/dashboard")
113
+ # @param actions [Array<Hash>] custom action definitions with `:key`, `:label`, `:method`, `:confirm`
114
+ #
115
+ # @return [PageRegistration] the created page registration
116
+ #
117
+ # @example Basic page
118
+ # page :dashboard, label: "Dashboard", icon: "home", controller: "admin/dashboard"
119
+ #
120
+ # @example Page with custom actions
121
+ # page :usage, label: "Usage Reports", controller: "admin/usage",
122
+ # actions: [
123
+ # { key: :index, label: "Overview" },
124
+ # { key: :export, label: "Export CSV", method: :post, confirm: "Export data?" }
125
+ # ]
126
+ #
127
+ # @see PageRegistration
128
+ def page(key, label:, controller:, icon: nil, actions: [])
129
+ registration = PageRegistration.build(
130
+ key: key,
131
+ label: label,
132
+ icon: icon,
133
+ controller: controller,
134
+ category_name: @name,
135
+ actions: actions
136
+ )
137
+ @pages << registration
138
+ registration
139
+ end
140
+
141
+ # Find a resource registration by its model class.
142
+ #
143
+ # @param model_class [Class] the ActiveRecord model class to search for
144
+ #
145
+ # @return [ResourceRegistration, nil] the matching resource registration, or nil if not found
146
+ #
147
+ # @example
148
+ # category.find_resource(User) #=> ResourceRegistration instance or nil
149
+ def find_resource(model_class)
150
+ @resources.find { |r| r.model_class == model_class }
151
+ end
152
+
153
+ # Merge another category's resources and pages into this category.
154
+ #
155
+ # This is used when multiple gems or initializers register resources
156
+ # in the same category. All resources and pages are combined.
157
+ #
158
+ # @param other [CategoryRegistration] the category to merge from
159
+ #
160
+ # @return [void]
161
+ #
162
+ # @example
163
+ # category1 = CategoryRegistration.new("Auth")
164
+ # category1.resource User, actions: [:index]
165
+ #
166
+ # category2 = CategoryRegistration.new("Auth")
167
+ # category2.resource Session, actions: [:index]
168
+ #
169
+ # category1.merge(category2)
170
+ # category1.resources.size #=> 2
171
+ def merge(other)
172
+ @resources.concat(other.resources)
173
+ @pages.concat(other.pages)
174
+ end
175
+ end
176
+ end
177
+ end