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,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RSB
|
|
4
|
+
module Admin
|
|
5
|
+
# Represents a column definition for admin resource tables.
|
|
6
|
+
#
|
|
7
|
+
# ColumnDefinition is an immutable data structure that describes how a column
|
|
8
|
+
# should be displayed in index and show views. It handles column metadata,
|
|
9
|
+
# formatting, sorting, and visibility rules.
|
|
10
|
+
#
|
|
11
|
+
# @!attribute [r] key
|
|
12
|
+
# @return [Symbol] the attribute name to display
|
|
13
|
+
# @!attribute [r] label
|
|
14
|
+
# @return [String] the human-readable column header
|
|
15
|
+
# @!attribute [r] sortable
|
|
16
|
+
# @return [Boolean] whether the column can be sorted
|
|
17
|
+
# @!attribute [r] formatter
|
|
18
|
+
# @return [Symbol, Proc, nil] optional formatter for the column value
|
|
19
|
+
# @!attribute [r] link
|
|
20
|
+
# @return [Boolean] whether to render the value as a link to the resource
|
|
21
|
+
# @!attribute [r] visible_on
|
|
22
|
+
# @return [Array<Symbol>] contexts where this column is visible (:index, :show)
|
|
23
|
+
#
|
|
24
|
+
# @example Building a simple column
|
|
25
|
+
# col = ColumnDefinition.build(:email)
|
|
26
|
+
# col.key #=> :email
|
|
27
|
+
# col.label #=> "Email"
|
|
28
|
+
# col.sortable #=> false
|
|
29
|
+
# col.link #=> false
|
|
30
|
+
#
|
|
31
|
+
# @example Building an ID column (link defaults to true)
|
|
32
|
+
# col = ColumnDefinition.build(:id)
|
|
33
|
+
# col.link #=> true
|
|
34
|
+
#
|
|
35
|
+
# @example Building a custom column with formatter
|
|
36
|
+
# col = ColumnDefinition.build(:status,
|
|
37
|
+
# label: "State",
|
|
38
|
+
# sortable: true,
|
|
39
|
+
# formatter: :badge,
|
|
40
|
+
# visible_on: [:index]
|
|
41
|
+
# )
|
|
42
|
+
ColumnDefinition = Data.define(
|
|
43
|
+
:key, # Symbol
|
|
44
|
+
:label, # String
|
|
45
|
+
:sortable, # Boolean
|
|
46
|
+
:formatter, # Symbol | Proc | nil
|
|
47
|
+
:link, # Boolean
|
|
48
|
+
:visible_on # Array<Symbol>
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
class ColumnDefinition
|
|
52
|
+
# Build a ColumnDefinition with smart defaults.
|
|
53
|
+
#
|
|
54
|
+
# @param key [Symbol, String] the attribute name
|
|
55
|
+
# @param label [String, nil] the display label (defaults to humanized key)
|
|
56
|
+
# @param sortable [Boolean] whether the column is sortable (default: false)
|
|
57
|
+
# @param formatter [Symbol, Proc, nil] optional value formatter
|
|
58
|
+
# @param link [Boolean, nil] whether to link the value (default: true for :id, false otherwise)
|
|
59
|
+
# @param visible_on [Symbol, Array<Symbol>] contexts where visible (default: [:index, :show])
|
|
60
|
+
# @return [ColumnDefinition] a frozen, immutable column definition
|
|
61
|
+
#
|
|
62
|
+
# @example
|
|
63
|
+
# ColumnDefinition.build(:created_at, label: "Created", sortable: true)
|
|
64
|
+
def self.build(key, label: nil, sortable: false, formatter: nil, link: nil, visible_on: %i[index show])
|
|
65
|
+
new(
|
|
66
|
+
key: key.to_sym,
|
|
67
|
+
label: label || key.to_s.humanize,
|
|
68
|
+
sortable: sortable,
|
|
69
|
+
formatter: formatter,
|
|
70
|
+
link: link.nil? ? (key.to_sym == :id) : link,
|
|
71
|
+
visible_on: Array(visible_on).map(&:to_sym)
|
|
72
|
+
)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Check if this column is visible in a given context.
|
|
76
|
+
#
|
|
77
|
+
# @param context [Symbol, String] the rendering context (:index or :show)
|
|
78
|
+
# @return [Boolean] true if the column should be displayed in this context
|
|
79
|
+
#
|
|
80
|
+
# @example
|
|
81
|
+
# col = ColumnDefinition.build(:email, visible_on: [:index])
|
|
82
|
+
# col.visible_on?(:index) #=> true
|
|
83
|
+
# col.visible_on?(:show) #=> false
|
|
84
|
+
def visible_on?(context)
|
|
85
|
+
visible_on.include?(context.to_sym)
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RSB
|
|
4
|
+
module Admin
|
|
5
|
+
# Holds configuration options for the RSB Admin panel.
|
|
6
|
+
#
|
|
7
|
+
# Instances are created by {RSB::Admin.configuration} and configured
|
|
8
|
+
# via {RSB::Admin.configure}. Each attribute has a sensible default
|
|
9
|
+
# that can be overridden in an initializer.
|
|
10
|
+
#
|
|
11
|
+
# @example
|
|
12
|
+
# RSB::Admin.configure do |config|
|
|
13
|
+
# config.app_name = "My App Admin"
|
|
14
|
+
# config.theme = :modern
|
|
15
|
+
# config.enabled = true
|
|
16
|
+
# config.company_name = "Acme Corp"
|
|
17
|
+
# config.logo_url = "/images/logo.svg"
|
|
18
|
+
# config.footer_text = "© 2024 Acme Corp"
|
|
19
|
+
# end
|
|
20
|
+
class Configuration
|
|
21
|
+
# @return [Boolean] whether the admin panel is enabled
|
|
22
|
+
attr_accessor :enabled
|
|
23
|
+
|
|
24
|
+
# @return [String] the display name shown in the admin panel header
|
|
25
|
+
attr_accessor :app_name
|
|
26
|
+
|
|
27
|
+
# @return [String] company or product name shown in footer/login
|
|
28
|
+
attr_accessor :company_name
|
|
29
|
+
|
|
30
|
+
# @return [String] URL to logo image shown in sidebar header
|
|
31
|
+
attr_accessor :logo_url
|
|
32
|
+
|
|
33
|
+
# @return [String] custom footer text
|
|
34
|
+
attr_accessor :footer_text
|
|
35
|
+
|
|
36
|
+
# @return [Integer] default number of records per page in index views
|
|
37
|
+
attr_accessor :per_page
|
|
38
|
+
|
|
39
|
+
# @return [Symbol] the active theme key (must match a registered theme)
|
|
40
|
+
attr_accessor :theme
|
|
41
|
+
|
|
42
|
+
# @return [String, nil] optional path prefix for host-app view overrides
|
|
43
|
+
attr_accessor :view_overrides_path
|
|
44
|
+
|
|
45
|
+
# @return [String] the layout template used by admin controllers
|
|
46
|
+
attr_accessor :layout
|
|
47
|
+
|
|
48
|
+
# @return [String] from address for admin emails
|
|
49
|
+
attr_accessor :mailer_sender
|
|
50
|
+
|
|
51
|
+
# @return [ActiveSupport::Duration] verification token lifetime
|
|
52
|
+
attr_accessor :email_verification_expiry
|
|
53
|
+
|
|
54
|
+
def initialize
|
|
55
|
+
@enabled = true
|
|
56
|
+
@app_name = 'Admin'
|
|
57
|
+
@company_name = ''
|
|
58
|
+
@logo_url = ''
|
|
59
|
+
@footer_text = ''
|
|
60
|
+
@per_page = 25
|
|
61
|
+
@theme = :default
|
|
62
|
+
@view_overrides_path = nil
|
|
63
|
+
@layout = 'rsb/admin/application'
|
|
64
|
+
@mailer_sender = 'no-reply@example.com'
|
|
65
|
+
@email_verification_expiry = 24.hours
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RSB
|
|
4
|
+
module Admin
|
|
5
|
+
class Engine < ::Rails::Engine
|
|
6
|
+
isolate_namespace RSB::Admin
|
|
7
|
+
|
|
8
|
+
initializer 'rsb_admin.i18n' do
|
|
9
|
+
config.i18n.load_path += Dir[RSB::Admin::Engine.root.join('config', 'locales', '**', '*.yml')]
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# Register settings schema
|
|
13
|
+
initializer 'rsb_admin.register_settings', after: 'rsb_settings.ready' do
|
|
14
|
+
RSB::Settings.registry.register(RSB::Admin.settings_schema)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Trigger on_load hooks — this is where rsb-auth, rsb-entitlements,
|
|
18
|
+
# and third-party gems register their admin sections.
|
|
19
|
+
# Deferred to after_initialize so all engine autoload paths are set up
|
|
20
|
+
# and model constants (e.g. RSB::Auth::Identity) are resolvable.
|
|
21
|
+
initializer 'rsb_admin.ready' do |app|
|
|
22
|
+
app.config.after_initialize do
|
|
23
|
+
ActiveSupport.run_load_hooks(:rsb_admin, RSB::Admin.registry)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
config.generators do |g|
|
|
28
|
+
g.test_framework :minitest, fixture: false
|
|
29
|
+
g.assets false
|
|
30
|
+
g.helper false
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RSB
|
|
4
|
+
module Admin
|
|
5
|
+
# Represents a filter definition for admin resource queries.
|
|
6
|
+
#
|
|
7
|
+
# FilterDefinition is an immutable data structure that describes how to filter
|
|
8
|
+
# an ActiveRecord relation. It supports multiple filter types (text, select,
|
|
9
|
+
# boolean, date ranges, number ranges) and can use custom scopes or default
|
|
10
|
+
# filtering logic.
|
|
11
|
+
#
|
|
12
|
+
# @!attribute [r] key
|
|
13
|
+
# @return [Symbol] the attribute name to filter on
|
|
14
|
+
# @!attribute [r] label
|
|
15
|
+
# @return [String] the human-readable filter label
|
|
16
|
+
# @!attribute [r] type
|
|
17
|
+
# @return [Symbol] the filter type (:text, :select, :boolean, :date_range, :number_range)
|
|
18
|
+
# @!attribute [r] options
|
|
19
|
+
# @return [Array, Proc, nil] options for select filters (array or callable)
|
|
20
|
+
# @!attribute [r] scope
|
|
21
|
+
# @return [Symbol, Proc, nil] custom scope method name or lambda for filtering
|
|
22
|
+
#
|
|
23
|
+
# @example Building a text filter
|
|
24
|
+
# filter = FilterDefinition.build(:email)
|
|
25
|
+
# filter.type #=> :text
|
|
26
|
+
#
|
|
27
|
+
# @example Building a select filter with options
|
|
28
|
+
# filter = FilterDefinition.build(:status,
|
|
29
|
+
# type: :select,
|
|
30
|
+
# options: %w[active suspended banned]
|
|
31
|
+
# )
|
|
32
|
+
#
|
|
33
|
+
# @example Building a filter with custom scope
|
|
34
|
+
# filter = FilterDefinition.build(:search,
|
|
35
|
+
# scope: ->(rel, val) { rel.where("name LIKE ? OR email LIKE ?", "%#{val}%", "%#{val}%") }
|
|
36
|
+
# )
|
|
37
|
+
FilterDefinition = Data.define(
|
|
38
|
+
:key, # Symbol
|
|
39
|
+
:label, # String
|
|
40
|
+
:type, # Symbol — :text, :select, :boolean, :date_range, :number_range
|
|
41
|
+
:options, # Array | Proc | nil
|
|
42
|
+
:scope # Symbol | Proc | nil
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
class FilterDefinition
|
|
46
|
+
# Build a FilterDefinition with smart defaults.
|
|
47
|
+
#
|
|
48
|
+
# @param key [Symbol, String] the attribute name to filter
|
|
49
|
+
# @param label [String, nil] the display label (defaults to humanized key)
|
|
50
|
+
# @param type [Symbol] the filter type (default: :text)
|
|
51
|
+
# @param options [Array, Proc, nil] options for select-type filters
|
|
52
|
+
# @param scope [Symbol, Proc, nil] custom filtering logic
|
|
53
|
+
# @return [FilterDefinition] a frozen, immutable filter definition
|
|
54
|
+
#
|
|
55
|
+
# @example
|
|
56
|
+
# FilterDefinition.build(:created_at, type: :date_range)
|
|
57
|
+
def self.build(key, label: nil, type: :text, options: nil, scope: nil)
|
|
58
|
+
new(
|
|
59
|
+
key: key.to_sym,
|
|
60
|
+
label: label || key.to_s.humanize,
|
|
61
|
+
type: type.to_sym,
|
|
62
|
+
options: options,
|
|
63
|
+
scope: scope
|
|
64
|
+
)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Apply this filter to an ActiveRecord relation.
|
|
68
|
+
#
|
|
69
|
+
# If the filter has a custom scope (Proc or Symbol), it will be used.
|
|
70
|
+
# Otherwise, default filtering logic based on the filter type will be applied.
|
|
71
|
+
#
|
|
72
|
+
# @param relation [ActiveRecord::Relation] the relation to filter
|
|
73
|
+
# @param value [Object] the filter value (ignored if blank)
|
|
74
|
+
# @return [ActiveRecord::Relation] the filtered relation
|
|
75
|
+
#
|
|
76
|
+
# @example Applying a text filter
|
|
77
|
+
# filter = FilterDefinition.build(:email, type: :text)
|
|
78
|
+
# filtered = filter.apply(User.all, "john")
|
|
79
|
+
# # Generates: WHERE email LIKE '%john%'
|
|
80
|
+
#
|
|
81
|
+
# @example Applying with blank value (no-op)
|
|
82
|
+
# filter.apply(User.all, "") #=> returns User.all unchanged
|
|
83
|
+
def apply(relation, value)
|
|
84
|
+
return relation if value.blank?
|
|
85
|
+
|
|
86
|
+
if scope.is_a?(Proc)
|
|
87
|
+
scope.call(relation, value)
|
|
88
|
+
elsif scope.is_a?(Symbol)
|
|
89
|
+
relation.send(scope, value)
|
|
90
|
+
else
|
|
91
|
+
apply_default_scope(relation, value)
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
private
|
|
96
|
+
|
|
97
|
+
# Apply default filtering logic based on the filter type.
|
|
98
|
+
#
|
|
99
|
+
# @param relation [ActiveRecord::Relation] the relation to filter
|
|
100
|
+
# @param value [Object] the filter value
|
|
101
|
+
# @return [ActiveRecord::Relation] the filtered relation
|
|
102
|
+
# @api private
|
|
103
|
+
def apply_default_scope(relation, value)
|
|
104
|
+
case type
|
|
105
|
+
when :text
|
|
106
|
+
relation.where("#{key} LIKE ?", "%#{value}%")
|
|
107
|
+
when :select, :boolean
|
|
108
|
+
relation.where(key => value)
|
|
109
|
+
when :date_range
|
|
110
|
+
from = value[:from]
|
|
111
|
+
to = value[:to]
|
|
112
|
+
scope = relation
|
|
113
|
+
scope = scope.where("#{key} >= ?", from) if from.present?
|
|
114
|
+
scope = scope.where("#{key} <= ?", to) if to.present?
|
|
115
|
+
scope
|
|
116
|
+
when :number_range
|
|
117
|
+
min = value[:min]
|
|
118
|
+
max = value[:max]
|
|
119
|
+
scope = relation
|
|
120
|
+
scope = scope.where("#{key} >= ?", min) if min.present?
|
|
121
|
+
scope = scope.where("#{key} <= ?", max) if max.present?
|
|
122
|
+
scope
|
|
123
|
+
else
|
|
124
|
+
relation.where(key => value)
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RSB
|
|
4
|
+
module Admin
|
|
5
|
+
# Represents a form field definition for admin resource forms.
|
|
6
|
+
#
|
|
7
|
+
# FormFieldDefinition is an immutable data structure that describes how a form
|
|
8
|
+
# field should be rendered and validated in new/edit forms. It handles field
|
|
9
|
+
# type, options, validation, hints, and visibility rules.
|
|
10
|
+
#
|
|
11
|
+
# @!attribute [r] key
|
|
12
|
+
# @return [Symbol] the attribute name for this field
|
|
13
|
+
# @!attribute [r] label
|
|
14
|
+
# @return [String] the human-readable field label
|
|
15
|
+
# @!attribute [r] type
|
|
16
|
+
# @return [Symbol] the field type (:text, :textarea, :select, :checkbox, :number, :email, :password, :datetime, :hidden, :json)
|
|
17
|
+
# @!attribute [r] options
|
|
18
|
+
# @return [Array, Proc, nil] options for select-type fields (array or callable)
|
|
19
|
+
# @!attribute [r] required
|
|
20
|
+
# @return [Boolean] whether the field is required
|
|
21
|
+
# @!attribute [r] hint
|
|
22
|
+
# @return [String, nil] optional help text displayed below the field
|
|
23
|
+
# @!attribute [r] visible_on
|
|
24
|
+
# @return [Array<Symbol>] contexts where this field is visible (:new, :edit)
|
|
25
|
+
#
|
|
26
|
+
# @example Building a simple text field
|
|
27
|
+
# field = FormFieldDefinition.build(:name)
|
|
28
|
+
# field.key #=> :name
|
|
29
|
+
# field.label #=> "Name"
|
|
30
|
+
# field.type #=> :text
|
|
31
|
+
# field.required #=> false
|
|
32
|
+
#
|
|
33
|
+
# @example Building a required email field
|
|
34
|
+
# field = FormFieldDefinition.build(:email,
|
|
35
|
+
# type: :email,
|
|
36
|
+
# required: true,
|
|
37
|
+
# hint: "We'll never share your email"
|
|
38
|
+
# )
|
|
39
|
+
#
|
|
40
|
+
# @example Building a select field with options
|
|
41
|
+
# field = FormFieldDefinition.build(:role,
|
|
42
|
+
# type: :select,
|
|
43
|
+
# options: %w[admin user guest],
|
|
44
|
+
# required: true
|
|
45
|
+
# )
|
|
46
|
+
FormFieldDefinition = Data.define(
|
|
47
|
+
:key, # Symbol
|
|
48
|
+
:label, # String
|
|
49
|
+
:type, # Symbol — :text, :textarea, :select, :checkbox, :number, :email, :password, :datetime, :hidden, :json
|
|
50
|
+
:options, # Array | Proc | nil
|
|
51
|
+
:required, # Boolean
|
|
52
|
+
:hint, # String | nil
|
|
53
|
+
:visible_on # Array<Symbol>
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
class FormFieldDefinition
|
|
57
|
+
# Build a FormFieldDefinition with smart defaults.
|
|
58
|
+
#
|
|
59
|
+
# @param key [Symbol, String] the attribute name
|
|
60
|
+
# @param label [String, nil] the display label (defaults to humanized key)
|
|
61
|
+
# @param type [Symbol] the field type (default: :text)
|
|
62
|
+
# @param options [Array, Proc, nil] options for select-type fields
|
|
63
|
+
# @param required [Boolean] whether the field is required (default: false)
|
|
64
|
+
# @param hint [String, nil] optional help text
|
|
65
|
+
# @param visible_on [Symbol, Array<Symbol>] contexts where visible (default: [:new, :edit])
|
|
66
|
+
# @return [FormFieldDefinition] a frozen, immutable form field definition
|
|
67
|
+
#
|
|
68
|
+
# @example
|
|
69
|
+
# FormFieldDefinition.build(:description, type: :textarea, required: true)
|
|
70
|
+
def self.build(key, label: nil, type: :text, options: nil, required: false, hint: nil, visible_on: %i[new edit])
|
|
71
|
+
new(
|
|
72
|
+
key: key.to_sym,
|
|
73
|
+
label: label || key.to_s.humanize,
|
|
74
|
+
type: type.to_sym,
|
|
75
|
+
options: options,
|
|
76
|
+
required: required,
|
|
77
|
+
hint: hint,
|
|
78
|
+
visible_on: Array(visible_on).map(&:to_sym)
|
|
79
|
+
)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Check if this field is visible in a given context.
|
|
83
|
+
#
|
|
84
|
+
# @param context [Symbol, String] the rendering context (:new or :edit)
|
|
85
|
+
# @return [Boolean] true if the field should be displayed in this context
|
|
86
|
+
#
|
|
87
|
+
# @example
|
|
88
|
+
# field = FormFieldDefinition.build(:password, visible_on: [:new])
|
|
89
|
+
# field.visible_on?(:new) #=> true
|
|
90
|
+
# field.visible_on?(:edit) #=> false
|
|
91
|
+
def visible_on?(context)
|
|
92
|
+
visible_on.include?(context.to_sym)
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RSB
|
|
4
|
+
module Admin
|
|
5
|
+
# Lucide icon system for the admin panel.
|
|
6
|
+
#
|
|
7
|
+
# Provides a curated subset of 30 Lucide icons as inline SVG strings.
|
|
8
|
+
# All icons use currentColor for stroke, allowing them to inherit text color.
|
|
9
|
+
#
|
|
10
|
+
# @example Render an icon
|
|
11
|
+
# RSB::Admin::Icons.render("users") # => "<svg ...>...</svg>"
|
|
12
|
+
# RSB::Admin::Icons.render("users", size: 24) # => "<svg width=\"24\" height=\"24\" ...>...</svg>"
|
|
13
|
+
#
|
|
14
|
+
# @example Unknown icon returns empty string (no error)
|
|
15
|
+
# RSB::Admin::Icons.render("nonexistent") # => ""
|
|
16
|
+
#
|
|
17
|
+
# @example List all available icons
|
|
18
|
+
# RSB::Admin::Icons::ICONS.keys # => ["home", "users", "mail", ...]
|
|
19
|
+
module Icons
|
|
20
|
+
# Hash of icon names to SVG markup strings.
|
|
21
|
+
# Each SVG uses the placeholder "SIZE" for width/height values.
|
|
22
|
+
# All SVGs use currentColor for stroke to inherit text color.
|
|
23
|
+
#
|
|
24
|
+
# Available icons: alert-circle, alert-triangle, arrow-down, arrow-up, bar-chart,
|
|
25
|
+
# check-circle, chevron-left, chevron-right, credit-card, edit, eye, filter, home,
|
|
26
|
+
# info, key, layout-dashboard, lock, log-out, mail, monitor, moon, plus, receipt,
|
|
27
|
+
# search, settings, shield, sun, trash, users, users-cog, x
|
|
28
|
+
ICONS = {
|
|
29
|
+
'home' => '<svg xmlns="http://www.w3.org/2000/svg" width="SIZE" height="SIZE" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8"/><path d="M3 10a2 2 0 0 1 .709-1.528l7-5.999a2 2 0 0 1 2.582 0l7 5.999A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg>',
|
|
30
|
+
'users' => '<svg xmlns="http://www.w3.org/2000/svg" width="SIZE" height="SIZE" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>',
|
|
31
|
+
'mail' => '<svg xmlns="http://www.w3.org/2000/svg" width="SIZE" height="SIZE" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="20" height="16" x="2" y="4" rx="2"/><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/></svg>',
|
|
32
|
+
'monitor' => '<svg xmlns="http://www.w3.org/2000/svg" width="SIZE" height="SIZE" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="20" height="14" x="2" y="3" rx="2"/><line x1="8" x2="16" y1="21" y2="21"/><line x1="12" x2="12" y1="17" y2="21"/></svg>',
|
|
33
|
+
'smartphone' => '<svg xmlns="http://www.w3.org/2000/svg" width="SIZE" height="SIZE" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="14" height="20" x="5" y="2" rx="2" ry="2"/><path d="M12 18h.01"/></svg>',
|
|
34
|
+
'tablet' => '<svg xmlns="http://www.w3.org/2000/svg" width="SIZE" height="SIZE" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="16" height="20" x="4" y="2" rx="2" ry="2"/><line x1="12" x2="12.01" y1="18" y2="18"/></svg>',
|
|
35
|
+
'credit-card' => '<svg xmlns="http://www.w3.org/2000/svg" width="SIZE" height="SIZE" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="20" height="14" x="2" y="5" rx="2"/><line x1="2" x2="22" y1="10" y2="10"/></svg>',
|
|
36
|
+
'shield' => '<svg xmlns="http://www.w3.org/2000/svg" width="SIZE" height="SIZE" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"/></svg>',
|
|
37
|
+
'bar-chart' => '<svg xmlns="http://www.w3.org/2000/svg" width="SIZE" height="SIZE" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" x2="12" y1="20" y2="10"/><line x1="18" x2="18" y1="20" y2="4"/><line x1="6" x2="6" y1="20" y2="16"/></svg>',
|
|
38
|
+
'settings' => '<svg xmlns="http://www.w3.org/2000/svg" width="SIZE" height="SIZE" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/></svg>',
|
|
39
|
+
'users-cog' => '<svg xmlns="http://www.w3.org/2000/svg" width="SIZE" height="SIZE" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><circle cx="19" cy="11" r="2"/><path d="M19 8v1"/><path d="M19 13v1"/><path d="m21.6 9.5-.87.5"/><path d="m17.27 12-.87.5"/><path d="m21.6 12.5-.87-.5"/><path d="m17.27 10-.87-.5"/></svg>',
|
|
40
|
+
'key' => '<svg xmlns="http://www.w3.org/2000/svg" width="SIZE" height="SIZE" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m15.5 7.5 2.3 2.3a1 1 0 0 0 1.4 0l2.1-2.1a1 1 0 0 0 0-1.4L19 4"/><path d="m21 2-9.6 9.6"/><circle cx="7.5" cy="15.5" r="5.5"/></svg>',
|
|
41
|
+
'lock' => '<svg xmlns="http://www.w3.org/2000/svg" width="SIZE" height="SIZE" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>',
|
|
42
|
+
'chevron-right' => '<svg xmlns="http://www.w3.org/2000/svg" width="SIZE" height="SIZE" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m9 18 6-6-6-6"/></svg>',
|
|
43
|
+
'chevron-left' => '<svg xmlns="http://www.w3.org/2000/svg" width="SIZE" height="SIZE" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m15 18-6-6 6-6"/></svg>',
|
|
44
|
+
'search' => '<svg xmlns="http://www.w3.org/2000/svg" width="SIZE" height="SIZE" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>',
|
|
45
|
+
'filter' => '<svg xmlns="http://www.w3.org/2000/svg" width="SIZE" height="SIZE" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"/></svg>',
|
|
46
|
+
'globe' => '<svg xmlns="http://www.w3.org/2000/svg" width="SIZE" height="SIZE" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20"/><path d="M2 12h20"/></svg>',
|
|
47
|
+
'plus' => '<svg xmlns="http://www.w3.org/2000/svg" width="SIZE" height="SIZE" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14"/><path d="M12 5v14"/></svg>',
|
|
48
|
+
'edit' => '<svg xmlns="http://www.w3.org/2000/svg" width="SIZE" height="SIZE" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z"/></svg>',
|
|
49
|
+
'trash' => '<svg xmlns="http://www.w3.org/2000/svg" width="SIZE" height="SIZE" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/></svg>',
|
|
50
|
+
'eye' => '<svg xmlns="http://www.w3.org/2000/svg" width="SIZE" height="SIZE" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0"/><circle cx="12" cy="12" r="3"/></svg>',
|
|
51
|
+
'log-out' => '<svg xmlns="http://www.w3.org/2000/svg" width="SIZE" height="SIZE" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" x2="9" y1="12" y2="12"/></svg>',
|
|
52
|
+
'arrow-up' => '<svg xmlns="http://www.w3.org/2000/svg" width="SIZE" height="SIZE" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m5 12 7-7 7 7"/><path d="M12 19V5"/></svg>',
|
|
53
|
+
'arrow-down' => '<svg xmlns="http://www.w3.org/2000/svg" width="SIZE" height="SIZE" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5v14"/><path d="m19 12-7 7-7-7"/></svg>',
|
|
54
|
+
'x' => '<svg xmlns="http://www.w3.org/2000/svg" width="SIZE" height="SIZE" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>',
|
|
55
|
+
'alert-circle' => '<svg xmlns="http://www.w3.org/2000/svg" width="SIZE" height="SIZE" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" x2="12" y1="8" y2="12"/><line x1="12" x2="12.01" y1="16" y2="16"/></svg>',
|
|
56
|
+
'alert-triangle' => '<svg xmlns="http://www.w3.org/2000/svg" width="SIZE" height="SIZE" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3"/><path d="M12 9v4"/><path d="M12 17h.01"/></svg>',
|
|
57
|
+
'check-circle' => '<svg xmlns="http://www.w3.org/2000/svg" width="SIZE" height="SIZE" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><path d="m9 11 3 3L22 4"/></svg>',
|
|
58
|
+
'info' => '<svg xmlns="http://www.w3.org/2000/svg" width="SIZE" height="SIZE" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></svg>',
|
|
59
|
+
'layout-dashboard' => '<svg xmlns="http://www.w3.org/2000/svg" width="SIZE" height="SIZE" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="7" height="9" x="3" y="3" rx="1"/><rect width="7" height="5" x="14" y="3" rx="1"/><rect width="7" height="9" x="14" y="12" rx="1"/><rect width="7" height="5" x="3" y="16" rx="1"/></svg>',
|
|
60
|
+
'sun' => '<svg xmlns="http://www.w3.org/2000/svg" width="SIZE" height="SIZE" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="4"/><path d="M12 2v2"/><path d="M12 20v2"/><path d="m4.93 4.93 1.41 1.41"/><path d="m17.66 17.66 1.41 1.41"/><path d="M2 12h2"/><path d="M20 12h2"/><path d="m6.34 17.66-1.41 1.41"/><path d="m19.07 4.93-1.41 1.41"/></svg>',
|
|
61
|
+
'moon' => '<svg xmlns="http://www.w3.org/2000/svg" width="SIZE" height="SIZE" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"/></svg>',
|
|
62
|
+
'receipt' => '<svg xmlns="http://www.w3.org/2000/svg" width="SIZE" height="SIZE" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 2v20l2-1 2 1 2-1 2 1 2-1 2 1 2-1 2 1V2l-2 1-2-1-2 1-2-1-2 1-2-1-2 1Z"/><path d="M14 8H8"/><path d="M16 12H8"/><path d="M13 16H8"/></svg>'
|
|
63
|
+
}.freeze
|
|
64
|
+
|
|
65
|
+
# Render an icon SVG string with specified dimensions.
|
|
66
|
+
#
|
|
67
|
+
# Looks up the icon by name in the ICONS hash and replaces the "SIZE"
|
|
68
|
+
# placeholder with the requested pixel size. If the icon is not found,
|
|
69
|
+
# returns an empty string (no error is raised - rule #9).
|
|
70
|
+
#
|
|
71
|
+
# @param name [String, Symbol] The icon name (e.g., "users", :home)
|
|
72
|
+
# @param size [Integer] The width and height of the icon in pixels
|
|
73
|
+
#
|
|
74
|
+
# @return [ActiveSupport::SafeBuffer] HTML-safe SVG string, or empty string if icon not found
|
|
75
|
+
#
|
|
76
|
+
# @example Render a users icon at default size
|
|
77
|
+
# RSB::Admin::Icons.render("users")
|
|
78
|
+
# # => '<svg xmlns="..." width="18" height="18" ...>...</svg>'
|
|
79
|
+
#
|
|
80
|
+
# @example Render a home icon at custom size
|
|
81
|
+
# RSB::Admin::Icons.render(:home, size: 32)
|
|
82
|
+
# # => '<svg xmlns="..." width="32" height="32" ...>...</svg>'
|
|
83
|
+
#
|
|
84
|
+
# @example Unknown icon returns empty string
|
|
85
|
+
# RSB::Admin::Icons.render("unknown")
|
|
86
|
+
# # => ""
|
|
87
|
+
def self.render(name, size: 18)
|
|
88
|
+
svg = ICONS[name.to_s]
|
|
89
|
+
return ''.html_safe unless svg
|
|
90
|
+
|
|
91
|
+
svg.gsub('SIZE', size.to_s).html_safe
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|