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.
- checksums.yaml +7 -0
- data/LICENSE +15 -0
- data/README.md +83 -0
- data/Rakefile +25 -0
- data/app/assets/javascripts/rsb/admin/themes/modern.js +37 -0
- data/app/assets/stylesheets/rsb/admin/themes/default.css +1358 -0
- data/app/assets/stylesheets/rsb/admin/themes/modern.css +1370 -0
- data/app/controllers/concerns/rsb/admin/authorization.rb +21 -0
- data/app/controllers/rsb/admin/admin_controller.rb +138 -0
- data/app/controllers/rsb/admin/admin_users_controller.rb +110 -0
- data/app/controllers/rsb/admin/dashboard_controller.rb +76 -0
- data/app/controllers/rsb/admin/profile_controller.rb +146 -0
- data/app/controllers/rsb/admin/profile_sessions_controller.rb +45 -0
- data/app/controllers/rsb/admin/resources_controller.rb +386 -0
- data/app/controllers/rsb/admin/roles_controller.rb +99 -0
- data/app/controllers/rsb/admin/sessions_controller.rb +139 -0
- data/app/controllers/rsb/admin/settings_controller.rb +203 -0
- data/app/controllers/rsb/admin/two_factor_controller.rb +105 -0
- data/app/helpers/rsb/admin/authorization_helper.rb +49 -0
- data/app/helpers/rsb/admin/branding_helper.rb +38 -0
- data/app/helpers/rsb/admin/formatting_helper.rb +205 -0
- data/app/helpers/rsb/admin/i18n_helper.rb +148 -0
- data/app/helpers/rsb/admin/icons_helper.rb +55 -0
- data/app/helpers/rsb/admin/table_helper.rb +132 -0
- data/app/helpers/rsb/admin/theme_helper.rb +84 -0
- data/app/helpers/rsb/admin/url_helper.rb +109 -0
- data/app/mailers/rsb/admin/admin_mailer.rb +37 -0
- data/app/models/rsb/admin/admin_session.rb +109 -0
- data/app/models/rsb/admin/admin_user.rb +153 -0
- data/app/models/rsb/admin/application_record.rb +10 -0
- data/app/models/rsb/admin/role.rb +63 -0
- data/app/views/layouts/rsb/admin/application.html.erb +45 -0
- data/app/views/rsb/admin/admin_mailer/email_verification.html.erb +11 -0
- data/app/views/rsb/admin/admin_mailer/email_verification.text.erb +11 -0
- data/app/views/rsb/admin/admin_users/_form.html.erb +52 -0
- data/app/views/rsb/admin/admin_users/edit.html.erb +10 -0
- data/app/views/rsb/admin/admin_users/index.html.erb +77 -0
- data/app/views/rsb/admin/admin_users/new.html.erb +10 -0
- data/app/views/rsb/admin/admin_users/show.html.erb +85 -0
- data/app/views/rsb/admin/dashboard/index.html.erb +36 -0
- data/app/views/rsb/admin/profile/edit.html.erb +67 -0
- data/app/views/rsb/admin/profile/show.html.erb +155 -0
- data/app/views/rsb/admin/resources/_filters.html.erb +58 -0
- data/app/views/rsb/admin/resources/_form.html.erb +20 -0
- data/app/views/rsb/admin/resources/_pagination.html.erb +33 -0
- data/app/views/rsb/admin/resources/_table.html.erb +70 -0
- data/app/views/rsb/admin/resources/edit.html.erb +7 -0
- data/app/views/rsb/admin/resources/index.html.erb +49 -0
- data/app/views/rsb/admin/resources/new.html.erb +7 -0
- data/app/views/rsb/admin/resources/page.html.erb +9 -0
- data/app/views/rsb/admin/resources/show.html.erb +55 -0
- data/app/views/rsb/admin/roles/_form.html.erb +197 -0
- data/app/views/rsb/admin/roles/edit.html.erb +7 -0
- data/app/views/rsb/admin/roles/index.html.erb +71 -0
- data/app/views/rsb/admin/roles/new.html.erb +7 -0
- data/app/views/rsb/admin/roles/show.html.erb +99 -0
- data/app/views/rsb/admin/sessions/new.html.erb +31 -0
- data/app/views/rsb/admin/sessions/two_factor.html.erb +39 -0
- data/app/views/rsb/admin/settings/_field.html.erb +115 -0
- data/app/views/rsb/admin/settings/index.html.erb +61 -0
- data/app/views/rsb/admin/shared/_badge.html.erb +1 -0
- data/app/views/rsb/admin/shared/_breadcrumbs.html.erb +12 -0
- data/app/views/rsb/admin/shared/_empty_state.html.erb +4 -0
- data/app/views/rsb/admin/shared/_flash.html.erb +22 -0
- data/app/views/rsb/admin/shared/_header.html.erb +50 -0
- data/app/views/rsb/admin/shared/_page_tabs.html.erb +21 -0
- data/app/views/rsb/admin/shared/_sidebar.html.erb +99 -0
- data/app/views/rsb/admin/shared/disabled.html.erb +38 -0
- data/app/views/rsb/admin/shared/fields/_checkbox.html.erb +6 -0
- data/app/views/rsb/admin/shared/fields/_datetime.html.erb +10 -0
- data/app/views/rsb/admin/shared/fields/_email.html.erb +10 -0
- data/app/views/rsb/admin/shared/fields/_hidden.html.erb +1 -0
- data/app/views/rsb/admin/shared/fields/_json.html.erb +11 -0
- data/app/views/rsb/admin/shared/fields/_number.html.erb +10 -0
- data/app/views/rsb/admin/shared/fields/_password.html.erb +10 -0
- data/app/views/rsb/admin/shared/fields/_select.html.erb +12 -0
- data/app/views/rsb/admin/shared/fields/_text.html.erb +10 -0
- data/app/views/rsb/admin/shared/fields/_textarea.html.erb +10 -0
- data/app/views/rsb/admin/shared/forbidden.html.erb +22 -0
- data/app/views/rsb/admin/themes/modern/views/shared/_header.html.erb +77 -0
- data/app/views/rsb/admin/themes/modern/views/shared/_sidebar.html.erb +135 -0
- data/app/views/rsb/admin/two_factor/backup_codes.html.erb +48 -0
- data/app/views/rsb/admin/two_factor/new.html.erb +53 -0
- data/config/locales/en.yml +140 -0
- data/config/locales/seo.en.yml +21 -0
- data/config/routes.rb +59 -0
- data/db/migrate/20260208000003_create_rsb_admin_tables.rb +43 -0
- data/db/migrate/20260214000001_add_otp_fields_to_rsb_admin_admin_users.rb +9 -0
- data/lib/generators/rsb/admin/install/install_generator.rb +45 -0
- data/lib/generators/rsb/admin/install/templates/rsb_admin_seeds.rb +24 -0
- data/lib/generators/rsb/admin/theme/templates/theme.css.tt +66 -0
- data/lib/generators/rsb/admin/theme/theme_generator.rb +218 -0
- data/lib/generators/rsb/admin/views/views_generator.rb +262 -0
- data/lib/rsb/admin/breadcrumb_item.rb +26 -0
- data/lib/rsb/admin/category_registration.rb +177 -0
- data/lib/rsb/admin/column_definition.rb +89 -0
- data/lib/rsb/admin/configuration.rb +69 -0
- data/lib/rsb/admin/engine.rb +34 -0
- data/lib/rsb/admin/filter_definition.rb +129 -0
- data/lib/rsb/admin/form_field_definition.rb +96 -0
- data/lib/rsb/admin/icons.rb +95 -0
- data/lib/rsb/admin/page_registration.rb +140 -0
- data/lib/rsb/admin/registry.rb +109 -0
- data/lib/rsb/admin/resource_dsl_context.rb +139 -0
- data/lib/rsb/admin/resource_registration.rb +287 -0
- data/lib/rsb/admin/settings_schema.rb +60 -0
- data/lib/rsb/admin/test_kit/helpers.rb +316 -0
- data/lib/rsb/admin/test_kit/resource_test_case.rb +193 -0
- data/lib/rsb/admin/test_kit.rb +11 -0
- data/lib/rsb/admin/theme_definition.rb +46 -0
- data/lib/rsb/admin/themes/modern.rb +44 -0
- data/lib/rsb/admin/version.rb +9 -0
- data/lib/rsb/admin.rb +177 -0
- data/lib/tasks/rsb/admin_tasks.rake +23 -0
- metadata +227 -0
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RSB
|
|
4
|
+
module Admin
|
|
5
|
+
# Represents a custom page registration in the admin panel.
|
|
6
|
+
#
|
|
7
|
+
# PageRegistration is an immutable data structure that describes a custom admin
|
|
8
|
+
# page (not a resource-based CRUD interface). It handles page metadata, controller
|
|
9
|
+
# routing, and custom actions.
|
|
10
|
+
#
|
|
11
|
+
# @!attribute [r] key
|
|
12
|
+
# @return [Symbol] unique identifier for this page
|
|
13
|
+
# @!attribute [r] label
|
|
14
|
+
# @return [String] the human-readable page name
|
|
15
|
+
# @!attribute [r] icon
|
|
16
|
+
# @return [String, nil] optional icon identifier
|
|
17
|
+
# @!attribute [r] controller
|
|
18
|
+
# @return [String] the controller path (e.g., "admin/dashboard")
|
|
19
|
+
# @!attribute [r] category_name
|
|
20
|
+
# @return [String] the category this page belongs to
|
|
21
|
+
# @!attribute [r] actions
|
|
22
|
+
# @return [Array<Hash>] array of action definitions with :key, :label, :method, :confirm
|
|
23
|
+
#
|
|
24
|
+
# @example Building a simple dashboard page
|
|
25
|
+
# page = PageRegistration.build(
|
|
26
|
+
# key: :dashboard,
|
|
27
|
+
# label: "Dashboard",
|
|
28
|
+
# icon: "home",
|
|
29
|
+
# controller: "admin/dashboard",
|
|
30
|
+
# category_name: "System"
|
|
31
|
+
# )
|
|
32
|
+
#
|
|
33
|
+
# @example Building a page with custom actions
|
|
34
|
+
# page = PageRegistration.build(
|
|
35
|
+
# key: :usage,
|
|
36
|
+
# label: "Usage Reports",
|
|
37
|
+
# controller: "admin/usage",
|
|
38
|
+
# category_name: "Billing",
|
|
39
|
+
# actions: [
|
|
40
|
+
# { key: :index, label: "Overview" },
|
|
41
|
+
# { key: :export, label: "Export CSV", method: :post }
|
|
42
|
+
# ]
|
|
43
|
+
# )
|
|
44
|
+
PageRegistration = Data.define(
|
|
45
|
+
:key, # Symbol
|
|
46
|
+
:label, # String
|
|
47
|
+
:icon, # String | nil
|
|
48
|
+
:controller, # String
|
|
49
|
+
:category_name, # String
|
|
50
|
+
:actions # Array<Hash> — [{key: :index, label: "Overview"}, ...]
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
class PageRegistration
|
|
54
|
+
# Build a PageRegistration with normalized actions.
|
|
55
|
+
#
|
|
56
|
+
# @param key [Symbol, String] unique identifier for the page
|
|
57
|
+
# @param label [String] the display label
|
|
58
|
+
# @param icon [String, nil] optional icon identifier
|
|
59
|
+
# @param controller [String] the controller path
|
|
60
|
+
# @param category_name [String] the category name
|
|
61
|
+
# @param actions [Array<Hash>] array of action definitions (default: [])
|
|
62
|
+
# @return [PageRegistration] a frozen, immutable page registration
|
|
63
|
+
#
|
|
64
|
+
# @example
|
|
65
|
+
# PageRegistration.build(
|
|
66
|
+
# key: :settings,
|
|
67
|
+
# label: "Settings",
|
|
68
|
+
# controller: "admin/settings",
|
|
69
|
+
# category_name: "System"
|
|
70
|
+
# )
|
|
71
|
+
def self.build(key:, label:, controller:, category_name:, icon: nil, actions: [])
|
|
72
|
+
new(
|
|
73
|
+
key: key.to_sym,
|
|
74
|
+
label: label,
|
|
75
|
+
icon: icon,
|
|
76
|
+
controller: controller,
|
|
77
|
+
category_name: category_name,
|
|
78
|
+
actions: normalize_actions(actions)
|
|
79
|
+
)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Wrap old-style Hash pages into PageRegistration for backwards compatibility.
|
|
83
|
+
#
|
|
84
|
+
# @param hash [Hash] legacy page hash with :key, :label, :icon, :controller, :category_name, :actions
|
|
85
|
+
# @return [PageRegistration] a frozen page registration
|
|
86
|
+
#
|
|
87
|
+
# @example
|
|
88
|
+
# legacy = { key: :sessions, label: "Sessions", controller: "admin/sessions", category_name: "Auth" }
|
|
89
|
+
# page = PageRegistration.from_hash(legacy)
|
|
90
|
+
def self.from_hash(hash)
|
|
91
|
+
build(
|
|
92
|
+
key: hash[:key],
|
|
93
|
+
label: hash[:label],
|
|
94
|
+
icon: hash[:icon],
|
|
95
|
+
controller: hash[:controller],
|
|
96
|
+
category_name: hash[:category_name],
|
|
97
|
+
actions: hash[:actions] || []
|
|
98
|
+
)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Get all action keys for this page.
|
|
102
|
+
#
|
|
103
|
+
# @return [Array<Symbol>] array of action keys
|
|
104
|
+
#
|
|
105
|
+
# @example
|
|
106
|
+
# page.action_keys #=> [:index, :show, :export]
|
|
107
|
+
def action_keys
|
|
108
|
+
actions.map { |a| a[:key] }
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Find an action definition by key.
|
|
112
|
+
#
|
|
113
|
+
# @param key [Symbol, String] the action key to find
|
|
114
|
+
# @return [Hash, nil] the action hash or nil if not found
|
|
115
|
+
#
|
|
116
|
+
# @example
|
|
117
|
+
# action = page.find_action(:export)
|
|
118
|
+
# action[:method] #=> :post
|
|
119
|
+
def find_action(key)
|
|
120
|
+
actions.find { |a| a[:key] == key.to_sym }
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Normalize action definitions with defaults.
|
|
124
|
+
#
|
|
125
|
+
# @param actions [Array<Hash>] raw action definitions
|
|
126
|
+
# @return [Array<Hash>] normalized action hashes
|
|
127
|
+
# @api private
|
|
128
|
+
private_class_method def self.normalize_actions(actions)
|
|
129
|
+
actions.map do |action|
|
|
130
|
+
{
|
|
131
|
+
key: action[:key].to_sym,
|
|
132
|
+
label: action[:label] || action[:key].to_s.humanize,
|
|
133
|
+
method: (action[:method] || :get).to_sym,
|
|
134
|
+
confirm: action[:confirm]
|
|
135
|
+
}
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RSB
|
|
4
|
+
module Admin
|
|
5
|
+
class Registry
|
|
6
|
+
attr_reader :categories, :dashboard_page
|
|
7
|
+
|
|
8
|
+
def initialize
|
|
9
|
+
@categories = {}
|
|
10
|
+
@dashboard_page = nil
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# Register a category with resources and pages
|
|
14
|
+
def register_category(name, &block)
|
|
15
|
+
category = @categories[name] ||= CategoryRegistration.new(name)
|
|
16
|
+
category.instance_eval(&block) if block_given?
|
|
17
|
+
category
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Register resources into an existing category
|
|
21
|
+
def register_in(category_name, &block)
|
|
22
|
+
register_category(category_name, &block)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Register a pre-built CategoryRegistration object
|
|
26
|
+
def register(category_registration)
|
|
27
|
+
name = category_registration.name
|
|
28
|
+
if @categories[name]
|
|
29
|
+
@categories[name].merge(category_registration)
|
|
30
|
+
else
|
|
31
|
+
@categories[name] = category_registration
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Register a custom dashboard page override.
|
|
36
|
+
#
|
|
37
|
+
# When registered, the built-in dashboard controller dispatches to the
|
|
38
|
+
# custom controller instead of rendering the default view. Calling this
|
|
39
|
+
# method multiple times replaces the previous registration (last-write-wins).
|
|
40
|
+
#
|
|
41
|
+
# @param controller [String] the controller path (e.g., "admin/dashboard")
|
|
42
|
+
# @param actions [Array<Hash>] optional action definitions for tab navigation
|
|
43
|
+
# (e.g., `[{ key: :index, label: "Overview" }, { key: :metrics, label: "Metrics" }]`)
|
|
44
|
+
#
|
|
45
|
+
# @return [PageRegistration] the created dashboard page registration
|
|
46
|
+
#
|
|
47
|
+
# @raise [ArgumentError] if controller is blank
|
|
48
|
+
#
|
|
49
|
+
# @example Simple override
|
|
50
|
+
# registry.register_dashboard(controller: "admin/dashboard")
|
|
51
|
+
#
|
|
52
|
+
# @example Override with tab actions
|
|
53
|
+
# registry.register_dashboard(
|
|
54
|
+
# controller: "admin/dashboard",
|
|
55
|
+
# actions: [
|
|
56
|
+
# { key: :index, label: "Overview" },
|
|
57
|
+
# { key: :metrics, label: "Metrics" }
|
|
58
|
+
# ]
|
|
59
|
+
# )
|
|
60
|
+
def register_dashboard(controller:, actions: [])
|
|
61
|
+
raise ArgumentError, 'controller must be present' if controller.nil? || controller.to_s.strip.empty?
|
|
62
|
+
|
|
63
|
+
@dashboard_page = PageRegistration.build(
|
|
64
|
+
key: :dashboard,
|
|
65
|
+
label: 'Dashboard',
|
|
66
|
+
icon: 'home',
|
|
67
|
+
controller: controller,
|
|
68
|
+
category_name: 'System',
|
|
69
|
+
actions: actions
|
|
70
|
+
)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Query
|
|
74
|
+
def find_resource(model_class)
|
|
75
|
+
@categories.each_value do |cat|
|
|
76
|
+
resource = cat.find_resource(model_class)
|
|
77
|
+
return resource if resource
|
|
78
|
+
end
|
|
79
|
+
nil
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def find_resource_by_route_key(key)
|
|
83
|
+
all_resources.find { |r| r.route_key == key }
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def find_page_by_key(key)
|
|
87
|
+
key_sym = key.to_sym
|
|
88
|
+
categories.each_value do |category|
|
|
89
|
+
category.pages.each do |page|
|
|
90
|
+
return page if page.key == key_sym
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
nil
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def category?(name)
|
|
97
|
+
@categories.key?(name)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def all_resources
|
|
101
|
+
@categories.values.flat_map(&:resources)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def all_pages
|
|
105
|
+
@categories.values.flat_map(&:pages)
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RSB
|
|
4
|
+
module Admin
|
|
5
|
+
# DSL context for defining resource columns, filters, and form fields inline.
|
|
6
|
+
#
|
|
7
|
+
# This class provides the block context for the resource registration DSL.
|
|
8
|
+
# It allows you to declaratively define columns, filters, and form fields
|
|
9
|
+
# within a resource registration block.
|
|
10
|
+
#
|
|
11
|
+
# @!attribute [r] columns
|
|
12
|
+
# @return [Array<ColumnDefinition>] the column definitions added via {#column}
|
|
13
|
+
#
|
|
14
|
+
# @!attribute [r] filters
|
|
15
|
+
# @return [Array<FilterDefinition>] the filter definitions added via {#filter}
|
|
16
|
+
#
|
|
17
|
+
# @!attribute [r] form_fields
|
|
18
|
+
# @return [Array<FormFieldDefinition>] the form field definitions added via {#form_field}
|
|
19
|
+
#
|
|
20
|
+
# @example Defining columns, filters, and form fields for a User resource
|
|
21
|
+
# registry.register_category "Users" do
|
|
22
|
+
# resource User, icon: "users", actions: [:index, :show] do
|
|
23
|
+
# column :id, link: true
|
|
24
|
+
# column :email, sortable: true
|
|
25
|
+
# column :status, formatter: :badge
|
|
26
|
+
#
|
|
27
|
+
# filter :email, type: :text
|
|
28
|
+
# filter :status, type: :select, options: %w[active suspended]
|
|
29
|
+
#
|
|
30
|
+
# form_field :email, type: :email, required: true
|
|
31
|
+
# form_field :name, type: :text, required: true
|
|
32
|
+
# end
|
|
33
|
+
# end
|
|
34
|
+
#
|
|
35
|
+
# @see CategoryRegistration#resource
|
|
36
|
+
# @see ColumnDefinition
|
|
37
|
+
# @see FilterDefinition
|
|
38
|
+
# @see FormFieldDefinition
|
|
39
|
+
class ResourceDSLContext
|
|
40
|
+
attr_reader :columns, :filters, :form_fields
|
|
41
|
+
|
|
42
|
+
# Initialize a new DSL context with empty arrays.
|
|
43
|
+
#
|
|
44
|
+
# @api private
|
|
45
|
+
def initialize
|
|
46
|
+
@columns = []
|
|
47
|
+
@filters = []
|
|
48
|
+
@form_fields = []
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Define a column to display on index and show pages.
|
|
52
|
+
#
|
|
53
|
+
# Columns control how data is displayed in tables and detail views.
|
|
54
|
+
# Options are passed directly to {ColumnDefinition.build}.
|
|
55
|
+
#
|
|
56
|
+
# @param key [Symbol, String] the attribute name to display
|
|
57
|
+
# @param options [Hash] column configuration options
|
|
58
|
+
# @option options [String] :label the human-readable column header
|
|
59
|
+
# @option options [Boolean] :sortable whether the column can be sorted
|
|
60
|
+
# @option options [Symbol, Proc] :formatter optional formatter for the column value
|
|
61
|
+
# @option options [Boolean] :link whether to render the value as a link
|
|
62
|
+
# @option options [Array<Symbol>] :visible_on contexts where visible (`:index`, `:show`)
|
|
63
|
+
#
|
|
64
|
+
# @return [void]
|
|
65
|
+
#
|
|
66
|
+
# @example Basic column
|
|
67
|
+
# column :email
|
|
68
|
+
#
|
|
69
|
+
# @example Column with options
|
|
70
|
+
# column :status, label: "Account Status", sortable: true, formatter: :badge
|
|
71
|
+
#
|
|
72
|
+
# @example Column visible only on show page
|
|
73
|
+
# column :notes, visible_on: [:show]
|
|
74
|
+
#
|
|
75
|
+
# @see ColumnDefinition.build
|
|
76
|
+
def column(key, **options)
|
|
77
|
+
@columns << ColumnDefinition.build(key, **options)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Define a filter for querying records on the index page.
|
|
81
|
+
#
|
|
82
|
+
# Filters allow users to narrow down the displayed records.
|
|
83
|
+
# Options are passed directly to {FilterDefinition.build}.
|
|
84
|
+
#
|
|
85
|
+
# @param key [Symbol, String] the attribute name to filter on
|
|
86
|
+
# @param options [Hash] filter configuration options
|
|
87
|
+
# @option options [String] :label the human-readable filter label
|
|
88
|
+
# @option options [Symbol] :type the filter type (`:text`, `:select`, `:boolean`, `:date_range`, `:number_range`)
|
|
89
|
+
# @option options [Array, Proc] :options options for select-type filters
|
|
90
|
+
# @option options [Symbol, Proc] :scope custom filtering logic
|
|
91
|
+
#
|
|
92
|
+
# @return [void]
|
|
93
|
+
#
|
|
94
|
+
# @example Text filter
|
|
95
|
+
# filter :email, type: :text
|
|
96
|
+
#
|
|
97
|
+
# @example Select filter with options
|
|
98
|
+
# filter :status, type: :select, options: %w[active suspended banned]
|
|
99
|
+
#
|
|
100
|
+
# @example Filter with custom scope
|
|
101
|
+
# filter :search, scope: ->(rel, val) { rel.where("name LIKE ? OR email LIKE ?", "%#{val}%", "%#{val}%") }
|
|
102
|
+
#
|
|
103
|
+
# @see FilterDefinition.build
|
|
104
|
+
def filter(key, **options)
|
|
105
|
+
@filters << FilterDefinition.build(key, **options)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Define a form field for new and edit forms.
|
|
109
|
+
#
|
|
110
|
+
# Form fields control how data is entered on create/update forms.
|
|
111
|
+
# Options are passed directly to {FormFieldDefinition.build}.
|
|
112
|
+
#
|
|
113
|
+
# @param key [Symbol, String] the attribute name for this field
|
|
114
|
+
# @param options [Hash] form field configuration options
|
|
115
|
+
# @option options [String] :label the human-readable field label
|
|
116
|
+
# @option options [Symbol] :type the field type (`:text`, `:textarea`, `:select`, `:checkbox`, `:number`, `:email`, `:password`, `:datetime`, `:hidden`, `:json`)
|
|
117
|
+
# @option options [Array, Proc] :options options for select-type fields
|
|
118
|
+
# @option options [Boolean] :required whether the field is required
|
|
119
|
+
# @option options [String] :hint optional help text displayed below the field
|
|
120
|
+
# @option options [Array<Symbol>] :visible_on contexts where visible (`:new`, `:edit`)
|
|
121
|
+
#
|
|
122
|
+
# @return [void]
|
|
123
|
+
#
|
|
124
|
+
# @example Required email field
|
|
125
|
+
# form_field :email, type: :email, required: true
|
|
126
|
+
#
|
|
127
|
+
# @example Textarea with hint
|
|
128
|
+
# form_field :bio, type: :textarea, hint: "Tell us about yourself"
|
|
129
|
+
#
|
|
130
|
+
# @example Field visible only on new form
|
|
131
|
+
# form_field :password, type: :password, required: true, visible_on: [:new]
|
|
132
|
+
#
|
|
133
|
+
# @see FormFieldDefinition.build
|
|
134
|
+
def form_field(key, **options)
|
|
135
|
+
@form_fields << FormFieldDefinition.build(key, **options)
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RSB
|
|
4
|
+
module Admin
|
|
5
|
+
# Represents a registered resource in the admin panel.
|
|
6
|
+
#
|
|
7
|
+
# ResourceRegistration stores metadata about a model that should be accessible
|
|
8
|
+
# in the admin interface, including its category, actions, icon, and optionally
|
|
9
|
+
# a custom controller to handle requests.
|
|
10
|
+
#
|
|
11
|
+
# @example Registering a resource with default generic controller
|
|
12
|
+
# ResourceRegistration.new(
|
|
13
|
+
# model_class: User,
|
|
14
|
+
# category_name: "Users",
|
|
15
|
+
# icon: "user",
|
|
16
|
+
# actions: [:index, :show]
|
|
17
|
+
# )
|
|
18
|
+
#
|
|
19
|
+
# @example Registering a resource with custom controller
|
|
20
|
+
# ResourceRegistration.new(
|
|
21
|
+
# model_class: Identity,
|
|
22
|
+
# category_name: "Authentication",
|
|
23
|
+
# icon: "users",
|
|
24
|
+
# actions: [:index, :show, :suspend, :activate],
|
|
25
|
+
# controller: "rsb/auth/admin/identities"
|
|
26
|
+
# )
|
|
27
|
+
class ResourceRegistration
|
|
28
|
+
# @return [Class] the ActiveRecord model class this registration represents
|
|
29
|
+
attr_reader :model_class
|
|
30
|
+
|
|
31
|
+
# @return [String] the name of the category this resource belongs to
|
|
32
|
+
attr_reader :category_name
|
|
33
|
+
|
|
34
|
+
# @return [String, nil] the icon identifier for this resource
|
|
35
|
+
attr_reader :icon
|
|
36
|
+
|
|
37
|
+
# @return [String] the human-readable label for this resource
|
|
38
|
+
attr_reader :label
|
|
39
|
+
|
|
40
|
+
# @return [Array<Symbol>] the list of allowed actions for this resource
|
|
41
|
+
attr_reader :actions
|
|
42
|
+
|
|
43
|
+
# @return [Hash] additional options passed during registration
|
|
44
|
+
attr_reader :options
|
|
45
|
+
|
|
46
|
+
# @return [String, nil] the controller path for custom controller (e.g., "rsb/auth/admin/identities")
|
|
47
|
+
attr_reader :controller
|
|
48
|
+
|
|
49
|
+
# @return [Array<ColumnDefinition>, nil] column definitions for this resource
|
|
50
|
+
attr_reader :columns
|
|
51
|
+
|
|
52
|
+
# @return [Array<FilterDefinition>, nil] filter definitions for this resource
|
|
53
|
+
attr_reader :filters
|
|
54
|
+
|
|
55
|
+
# @return [Array<FormFieldDefinition>, nil] form field definitions for this resource
|
|
56
|
+
attr_reader :form_fields
|
|
57
|
+
|
|
58
|
+
# @return [Integer, nil] number of records per page
|
|
59
|
+
attr_reader :per_page
|
|
60
|
+
|
|
61
|
+
# @return [Hash, nil] default sort configuration (e.g., { column: :created_at, direction: :desc })
|
|
62
|
+
attr_reader :default_sort
|
|
63
|
+
|
|
64
|
+
# @return [Array<Symbol>, nil] searchable field names
|
|
65
|
+
attr_reader :search_fields
|
|
66
|
+
|
|
67
|
+
# Initialize a new resource registration.
|
|
68
|
+
#
|
|
69
|
+
# @param model_class [Class] the ActiveRecord model class
|
|
70
|
+
# @param category_name [String] the category this resource belongs to
|
|
71
|
+
# @param icon [String, nil] optional icon identifier
|
|
72
|
+
# @param label [String, nil] optional custom label (defaults to humanized plural model name)
|
|
73
|
+
# @param actions [Array<Symbol>] allowed actions (default: [])
|
|
74
|
+
# @param controller [String, nil] optional custom controller path for delegation
|
|
75
|
+
# @param columns [Array<ColumnDefinition>, nil] column definitions
|
|
76
|
+
# @param filters [Array<FilterDefinition>, nil] filter definitions
|
|
77
|
+
# @param form_fields [Array<FormFieldDefinition>, nil] form field definitions
|
|
78
|
+
# @param per_page [Integer, nil] records per page
|
|
79
|
+
# @param default_sort [Hash, nil] default sort configuration
|
|
80
|
+
# @param search_fields [Array<Symbol>, nil] searchable field names
|
|
81
|
+
# @param options [Hash] additional options
|
|
82
|
+
def initialize(model_class:, category_name:, icon: nil, label: nil, actions: [], controller: nil,
|
|
83
|
+
columns: nil, filters: nil, form_fields: nil,
|
|
84
|
+
per_page: nil, default_sort: nil, search_fields: nil,
|
|
85
|
+
**options)
|
|
86
|
+
@model_class = model_class
|
|
87
|
+
@category_name = category_name
|
|
88
|
+
@icon = icon
|
|
89
|
+
@label = label || model_class.model_name.human.pluralize
|
|
90
|
+
@actions = actions.map(&:to_sym)
|
|
91
|
+
@controller = controller
|
|
92
|
+
@columns = columns
|
|
93
|
+
@filters = filters
|
|
94
|
+
@form_fields = form_fields
|
|
95
|
+
@per_page = per_page
|
|
96
|
+
@default_sort = default_sort
|
|
97
|
+
@search_fields = search_fields&.map(&:to_sym)
|
|
98
|
+
@options = options
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Check if a specific action is allowed for this resource.
|
|
102
|
+
#
|
|
103
|
+
# @param action [Symbol, String] the action to check
|
|
104
|
+
# @return [Boolean] true if the action is in the allowed actions list
|
|
105
|
+
def action?(action)
|
|
106
|
+
@actions.include?(action.to_sym)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Check if this resource uses a custom controller.
|
|
110
|
+
#
|
|
111
|
+
# When true, requests to this resource will be delegated to the custom
|
|
112
|
+
# controller instead of being handled by the generic ResourcesController.
|
|
113
|
+
#
|
|
114
|
+
# @return [Boolean] true if a custom controller is configured
|
|
115
|
+
def custom_controller?
|
|
116
|
+
@controller.present?
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Get the route key for this resource's model.
|
|
120
|
+
#
|
|
121
|
+
# @return [String] the pluralized route key (e.g., "identities" for Identity model)
|
|
122
|
+
def route_key
|
|
123
|
+
model_class.model_name.route_key
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Returns columns visible on index views.
|
|
127
|
+
#
|
|
128
|
+
# If no columns were explicitly defined via the DSL, this method
|
|
129
|
+
# auto-detects columns from the model's database schema, excluding
|
|
130
|
+
# sensitive columns (passwords, tokens, etc.).
|
|
131
|
+
#
|
|
132
|
+
# @return [Array<ColumnDefinition>] columns to display on index view
|
|
133
|
+
#
|
|
134
|
+
# @example With explicit columns
|
|
135
|
+
# registration.columns #=> [ColumnDefinition(:id), ColumnDefinition(:email)]
|
|
136
|
+
# registration.index_columns #=> [ColumnDefinition(:id), ColumnDefinition(:email)]
|
|
137
|
+
#
|
|
138
|
+
# @example With auto-detection
|
|
139
|
+
# registration.columns #=> nil
|
|
140
|
+
# registration.index_columns #=> [ColumnDefinition(:id), ColumnDefinition(:email), ...]
|
|
141
|
+
#
|
|
142
|
+
# @see #auto_detect_columns
|
|
143
|
+
def index_columns
|
|
144
|
+
return auto_detect_columns.select { |c| c.visible_on?(:index) } unless columns
|
|
145
|
+
|
|
146
|
+
columns.select { |c| c.visible_on?(:index) }
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Returns columns visible on show (detail) views.
|
|
150
|
+
#
|
|
151
|
+
# If no columns were explicitly defined via the DSL, this method
|
|
152
|
+
# auto-detects columns from the model's database schema, excluding
|
|
153
|
+
# sensitive columns (passwords, tokens, etc.).
|
|
154
|
+
#
|
|
155
|
+
# Show views typically display more columns than index views, including
|
|
156
|
+
# timestamps and metadata fields.
|
|
157
|
+
#
|
|
158
|
+
# @return [Array<ColumnDefinition>] columns to display on show view
|
|
159
|
+
#
|
|
160
|
+
# @see #auto_detect_columns
|
|
161
|
+
def show_columns
|
|
162
|
+
return auto_detect_columns.select { |c| c.visible_on?(:show) } unless columns
|
|
163
|
+
|
|
164
|
+
columns.select { |c| c.visible_on?(:show) }
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Returns form fields for new (create) forms.
|
|
168
|
+
#
|
|
169
|
+
# If no form fields were explicitly defined via the DSL, this method
|
|
170
|
+
# auto-detects editable fields from the model's database schema,
|
|
171
|
+
# excluding sensitive columns, ID, and timestamps.
|
|
172
|
+
#
|
|
173
|
+
# @return [Array<FormFieldDefinition>] form fields for new form
|
|
174
|
+
#
|
|
175
|
+
# @example With explicit form fields
|
|
176
|
+
# registration.form_fields #=> [FormFieldDefinition(:email), FormFieldDefinition(:name)]
|
|
177
|
+
# registration.new_form_fields #=> [FormFieldDefinition(:email), FormFieldDefinition(:name)]
|
|
178
|
+
#
|
|
179
|
+
# @example With auto-detection
|
|
180
|
+
# registration.form_fields #=> nil
|
|
181
|
+
# registration.new_form_fields #=> [FormFieldDefinition(:email), FormFieldDefinition(:name), ...]
|
|
182
|
+
#
|
|
183
|
+
# @see #auto_detect_form_fields
|
|
184
|
+
def new_form_fields
|
|
185
|
+
return auto_detect_form_fields.select { |f| f.visible_on?(:new) } unless form_fields
|
|
186
|
+
|
|
187
|
+
form_fields.select { |f| f.visible_on?(:new) }
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# Returns form fields for edit (update) forms.
|
|
191
|
+
#
|
|
192
|
+
# If no form fields were explicitly defined via the DSL, this method
|
|
193
|
+
# auto-detects editable fields from the model's database schema,
|
|
194
|
+
# excluding sensitive columns, ID, and timestamps.
|
|
195
|
+
#
|
|
196
|
+
# @return [Array<FormFieldDefinition>] form fields for edit form
|
|
197
|
+
#
|
|
198
|
+
# @see #auto_detect_form_fields
|
|
199
|
+
def edit_form_fields
|
|
200
|
+
return auto_detect_form_fields.select { |f| f.visible_on?(:edit) } unless form_fields
|
|
201
|
+
|
|
202
|
+
form_fields.select { |f| f.visible_on?(:edit) }
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
private
|
|
206
|
+
|
|
207
|
+
# Column names that contain sensitive data and should never be displayed.
|
|
208
|
+
#
|
|
209
|
+
# These columns are excluded from auto-detection to prevent accidental
|
|
210
|
+
# exposure of passwords, tokens, and other secrets in the admin interface.
|
|
211
|
+
#
|
|
212
|
+
# @api private
|
|
213
|
+
SENSITIVE_COLUMNS = %w[
|
|
214
|
+
password_digest token encrypted_password
|
|
215
|
+
reset_password_token confirmation_token
|
|
216
|
+
unlock_token otp_secret
|
|
217
|
+
].freeze
|
|
218
|
+
|
|
219
|
+
# Column names that should be excluded from auto-detected form fields.
|
|
220
|
+
#
|
|
221
|
+
# These are typically read-only columns managed by the database or
|
|
222
|
+
# framework (ID, timestamps) that users should not edit directly.
|
|
223
|
+
#
|
|
224
|
+
# @api private
|
|
225
|
+
SKIP_FORM_COLUMNS = %w[
|
|
226
|
+
id created_at updated_at
|
|
227
|
+
].freeze
|
|
228
|
+
|
|
229
|
+
# Column names that should be excluded from index tables.
|
|
230
|
+
#
|
|
231
|
+
# These are typically noisy columns (timestamps, metadata) that clutter
|
|
232
|
+
# table views but are useful in detail views.
|
|
233
|
+
#
|
|
234
|
+
# @api private
|
|
235
|
+
SKIP_INDEX_COLUMNS = %w[
|
|
236
|
+
created_at updated_at metadata
|
|
237
|
+
].freeze
|
|
238
|
+
|
|
239
|
+
# Auto-detect column definitions from the model's database schema.
|
|
240
|
+
#
|
|
241
|
+
# This method introspects the model's columns and creates a
|
|
242
|
+
# {ColumnDefinition} for each non-sensitive column. Used as a fallback
|
|
243
|
+
# when no explicit columns are defined via the DSL.
|
|
244
|
+
#
|
|
245
|
+
# Columns in SKIP_INDEX_COLUMNS are marked as visible only on show views,
|
|
246
|
+
# not index views.
|
|
247
|
+
#
|
|
248
|
+
# @return [Array<ColumnDefinition>] auto-detected column definitions
|
|
249
|
+
# @return [Array] empty array if the model doesn't respond to `column_names`
|
|
250
|
+
#
|
|
251
|
+
# @api private
|
|
252
|
+
def auto_detect_columns
|
|
253
|
+
return [] unless model_class.respond_to?(:column_names)
|
|
254
|
+
|
|
255
|
+
model_class.column_names
|
|
256
|
+
.reject { |c| SENSITIVE_COLUMNS.include?(c) }
|
|
257
|
+
.map do |c|
|
|
258
|
+
# Skip index for timestamp/metadata columns
|
|
259
|
+
if SKIP_INDEX_COLUMNS.include?(c)
|
|
260
|
+
ColumnDefinition.build(c.to_sym, visible_on: [:show])
|
|
261
|
+
else
|
|
262
|
+
ColumnDefinition.build(c.to_sym)
|
|
263
|
+
end
|
|
264
|
+
end
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
# Auto-detect form field definitions from the model's database schema.
|
|
268
|
+
#
|
|
269
|
+
# This method introspects the model's columns and creates a
|
|
270
|
+
# {FormFieldDefinition} for each editable column. Excludes sensitive
|
|
271
|
+
# columns, ID, and timestamps. Used as a fallback when no explicit
|
|
272
|
+
# form fields are defined via the DSL.
|
|
273
|
+
#
|
|
274
|
+
# @return [Array<FormFieldDefinition>] auto-detected form field definitions
|
|
275
|
+
# @return [Array] empty array if the model doesn't respond to `column_names`
|
|
276
|
+
#
|
|
277
|
+
# @api private
|
|
278
|
+
def auto_detect_form_fields
|
|
279
|
+
return [] unless model_class.respond_to?(:column_names)
|
|
280
|
+
|
|
281
|
+
model_class.column_names
|
|
282
|
+
.reject { |c| SENSITIVE_COLUMNS.include?(c) || SKIP_FORM_COLUMNS.include?(c) }
|
|
283
|
+
.map { |c| FormFieldDefinition.build(c.to_sym) }
|
|
284
|
+
end
|
|
285
|
+
end
|
|
286
|
+
end
|
|
287
|
+
end
|