super_admin 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/README.md +216 -0
- data/Rakefile +30 -0
- data/app/assets/stylesheets/super_admin/application.css +15 -0
- data/app/assets/stylesheets/super_admin/tailwind.css +1 -0
- data/app/assets/stylesheets/super_admin/tailwind.source.css +25 -0
- data/app/controllers/super_admin/application_controller.rb +89 -0
- data/app/controllers/super_admin/associations_controller.rb +136 -0
- data/app/controllers/super_admin/audit_logs_controller.rb +39 -0
- data/app/controllers/super_admin/base_controller.rb +133 -0
- data/app/controllers/super_admin/dashboard_controller.rb +29 -0
- data/app/controllers/super_admin/exports_controller.rb +109 -0
- data/app/controllers/super_admin/resources_controller.rb +201 -0
- data/app/dashboards/super_admin/base_dashboard.rb +200 -0
- data/app/errors/super_admin/configuration_error.rb +6 -0
- data/app/helpers/super_admin/application_helper.rb +84 -0
- data/app/helpers/super_admin/exports_helper.rb +16 -0
- data/app/helpers/super_admin/resources_helper.rb +204 -0
- data/app/helpers/super_admin/route_helper.rb +7 -0
- data/app/javascript/super_admin/application.js +263 -0
- data/app/jobs/super_admin/application_job.rb +4 -0
- data/app/jobs/super_admin/generate_super_admin_csv_export_job.rb +100 -0
- data/app/mailers/super_admin/application_mailer.rb +6 -0
- data/app/models/super_admin/application_record.rb +5 -0
- data/app/models/super_admin/audit_log.rb +35 -0
- data/app/models/super_admin/csv_export.rb +67 -0
- data/app/services/super_admin/auditing.rb +74 -0
- data/app/services/super_admin/authorization.rb +113 -0
- data/app/services/super_admin/authorization_adapters/base_adapter.rb +100 -0
- data/app/services/super_admin/authorization_adapters/default_adapter.rb +77 -0
- data/app/services/super_admin/authorization_adapters/proc_adapter.rb +65 -0
- data/app/services/super_admin/authorization_adapters/pundit_adapter.rb +81 -0
- data/app/services/super_admin/csv_export_creator.rb +45 -0
- data/app/services/super_admin/dashboard_registry.rb +90 -0
- data/app/services/super_admin/dashboard_resolver.rb +100 -0
- data/app/services/super_admin/filter_builder.rb +185 -0
- data/app/services/super_admin/form_builder.rb +59 -0
- data/app/services/super_admin/form_fields/array_field.rb +35 -0
- data/app/services/super_admin/form_fields/association_field.rb +146 -0
- data/app/services/super_admin/form_fields/base_field.rb +53 -0
- data/app/services/super_admin/form_fields/boolean_field.rb +29 -0
- data/app/services/super_admin/form_fields/date_field.rb +15 -0
- data/app/services/super_admin/form_fields/date_time_field.rb +15 -0
- data/app/services/super_admin/form_fields/enum_field.rb +27 -0
- data/app/services/super_admin/form_fields/factory.rb +102 -0
- data/app/services/super_admin/form_fields/nested_field.rb +120 -0
- data/app/services/super_admin/form_fields/number_field.rb +29 -0
- data/app/services/super_admin/form_fields/text_area_field.rb +19 -0
- data/app/services/super_admin/model_inspector.rb +182 -0
- data/app/services/super_admin/queries/base_query.rb +45 -0
- data/app/services/super_admin/queries/filter_query.rb +188 -0
- data/app/services/super_admin/queries/resource_scope_query.rb +74 -0
- data/app/services/super_admin/queries/search_query.rb +146 -0
- data/app/services/super_admin/queries/sort_query.rb +41 -0
- data/app/services/super_admin/resource_configuration.rb +63 -0
- data/app/services/super_admin/resource_exporter.rb +78 -0
- data/app/services/super_admin/resource_query.rb +40 -0
- data/app/services/super_admin/resources/association_inspector.rb +112 -0
- data/app/services/super_admin/resources/collection_presenter.rb +63 -0
- data/app/services/super_admin/resources/context.rb +63 -0
- data/app/services/super_admin/resources/filter_params.rb +29 -0
- data/app/services/super_admin/resources/permitted_attributes.rb +104 -0
- data/app/services/super_admin/resources/value_normalizer.rb +121 -0
- data/app/services/super_admin/sensitive_attributes.rb +166 -0
- data/app/views/layouts/super_admin.html.erb +74 -0
- data/app/views/super_admin/audit_logs/index.html.erb +143 -0
- data/app/views/super_admin/dashboard/index.html.erb +79 -0
- data/app/views/super_admin/exports/index.html.erb +84 -0
- data/app/views/super_admin/exports/show.html.erb +57 -0
- data/app/views/super_admin/resources/_form.html.erb +42 -0
- data/app/views/super_admin/resources/destroy.turbo_stream.erb +17 -0
- data/app/views/super_admin/resources/edit.html.erb +37 -0
- data/app/views/super_admin/resources/index.html.erb +189 -0
- data/app/views/super_admin/resources/new.html.erb +31 -0
- data/app/views/super_admin/resources/show.html.erb +106 -0
- data/app/views/super_admin/shared/_breadcrumbs.html.erb +12 -0
- data/app/views/super_admin/shared/_custom_styles.html.erb +132 -0
- data/app/views/super_admin/shared/_flash.html.erb +55 -0
- data/app/views/super_admin/shared/_form_field.html.erb +35 -0
- data/app/views/super_admin/shared/_navigation.html.erb +92 -0
- data/app/views/super_admin/shared/_nested_fields.html.erb +59 -0
- data/app/views/super_admin/shared/_nested_record_fields.html.erb +45 -0
- data/config/importmap.rb +4 -0
- data/config/initializers/rack_attack.rb +134 -0
- data/config/initializers/super_admin.rb +117 -0
- data/config/locales/super_admin.en.yml +197 -0
- data/config/locales/super_admin.fr.yml +197 -0
- data/config/routes.rb +22 -0
- data/lib/generators/super_admin/dashboard_generator.rb +50 -0
- data/lib/generators/super_admin/install_generator.rb +58 -0
- data/lib/generators/super_admin/templates/20240101000001_create_super_admin_audit_logs.rb +24 -0
- data/lib/generators/super_admin/templates/20240101000002_create_super_admin_csv_exports.rb +33 -0
- data/lib/generators/super_admin/templates/super_admin.rb +58 -0
- data/lib/super_admin/dashboard_creator.rb +256 -0
- data/lib/super_admin/engine.rb +53 -0
- data/lib/super_admin/install_task.rb +96 -0
- data/lib/super_admin/version.rb +3 -0
- data/lib/super_admin.rb +7 -0
- data/lib/tasks/super_admin_tasks.rake +38 -0
- metadata +239 -0
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "singleton"
|
|
4
|
+
|
|
5
|
+
module SuperAdmin
|
|
6
|
+
# Discovers and caches dashboard classes for SuperAdmin resources.
|
|
7
|
+
class DashboardRegistry
|
|
8
|
+
include Singleton
|
|
9
|
+
|
|
10
|
+
def initialize
|
|
11
|
+
@dashboards = {}
|
|
12
|
+
@resource_classes = {}
|
|
13
|
+
@loaded = false
|
|
14
|
+
setup_reloader
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Returns the dashboard class for a given model or resource name.
|
|
18
|
+
# @param model [Class, String, Symbol]
|
|
19
|
+
# @return [Class, nil]
|
|
20
|
+
def dashboard_for(model)
|
|
21
|
+
load_dashboards unless @loaded
|
|
22
|
+
|
|
23
|
+
@dashboards[normalize_model_name(model)]
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Returns the list of resource classes that have an associated dashboard.
|
|
27
|
+
# @return [Array<Class>]
|
|
28
|
+
def resource_classes
|
|
29
|
+
load_dashboards unless @loaded
|
|
30
|
+
|
|
31
|
+
@resource_classes.values.sort_by(&:name)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Reloads dashboard definitions (used in development).
|
|
35
|
+
def reload!
|
|
36
|
+
@dashboards.clear
|
|
37
|
+
@resource_classes.clear
|
|
38
|
+
@loaded = false
|
|
39
|
+
load_dashboards
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def setup_reloader
|
|
45
|
+
return unless defined?(ActiveSupport::Reloader)
|
|
46
|
+
|
|
47
|
+
ActiveSupport::Reloader.to_prepare do
|
|
48
|
+
@dashboards ||= {}
|
|
49
|
+
@dashboards.clear
|
|
50
|
+
@resource_classes ||= {}
|
|
51
|
+
@resource_classes.clear
|
|
52
|
+
@loaded = false
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def load_dashboards
|
|
57
|
+
eager_load_dashboard_files
|
|
58
|
+
register_dashboard_classes
|
|
59
|
+
@loaded = true
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def eager_load_dashboard_files
|
|
63
|
+
pattern = Rails.root.join("app/dashboards/**/*_dashboard.rb")
|
|
64
|
+
Dir[pattern].each { |file| require_dependency(file) }
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def register_dashboard_classes
|
|
68
|
+
ObjectSpace.each_object(Class) do |klass|
|
|
69
|
+
next unless klass < SuperAdmin::BaseDashboard
|
|
70
|
+
|
|
71
|
+
resource_class = klass.resource_class
|
|
72
|
+
next unless resource_class
|
|
73
|
+
|
|
74
|
+
@dashboards[resource_class.name] = klass
|
|
75
|
+
@resource_classes[resource_class.name] = resource_class
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def normalize_model_name(model)
|
|
80
|
+
case model
|
|
81
|
+
when Class
|
|
82
|
+
model.name
|
|
83
|
+
when String, Symbol
|
|
84
|
+
model.to_s.underscore.singularize.camelize
|
|
85
|
+
else
|
|
86
|
+
model.class.name
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SuperAdmin
|
|
4
|
+
# Provides accessors and fallbacks for dashboard-driven configuration.
|
|
5
|
+
class DashboardResolver
|
|
6
|
+
VIEWS = {
|
|
7
|
+
index: :collection,
|
|
8
|
+
collection: :collection,
|
|
9
|
+
list: :collection,
|
|
10
|
+
show: :show,
|
|
11
|
+
detail: :show,
|
|
12
|
+
form: :form,
|
|
13
|
+
new: :form,
|
|
14
|
+
edit: :form
|
|
15
|
+
}.freeze
|
|
16
|
+
|
|
17
|
+
class << self
|
|
18
|
+
def dashboard_for(model_class)
|
|
19
|
+
DashboardRegistry.instance.dashboard_for(model_class)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def collection_attributes_for(model_class)
|
|
23
|
+
attributes_for(model_class, :collection)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def show_attributes_for(model_class)
|
|
27
|
+
attributes_for(model_class, :show)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def form_attributes_for(model_class)
|
|
31
|
+
attributes_for(model_class, :form)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def collection_includes_for(model_class)
|
|
35
|
+
includes_for(model_class, :collection)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def show_includes_for(model_class)
|
|
39
|
+
includes_for(model_class, :show)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def includes_for(model_class, view)
|
|
43
|
+
dashboard = dashboard_for(model_class)
|
|
44
|
+
return [] unless dashboard
|
|
45
|
+
|
|
46
|
+
case view
|
|
47
|
+
when :collection, :index, :list
|
|
48
|
+
Array.wrap(dashboard.collection_includes_list)
|
|
49
|
+
when :show, :detail
|
|
50
|
+
Array.wrap(dashboard.show_includes_list)
|
|
51
|
+
else
|
|
52
|
+
[]
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def attributes_for(model_class, view)
|
|
57
|
+
normalized_view = VIEWS.fetch(view.to_sym, view.to_sym)
|
|
58
|
+
dashboard = dashboard_for(model_class)
|
|
59
|
+
|
|
60
|
+
if dashboard
|
|
61
|
+
Array.wrap(dashboard.attributes_for(normalized_view)).map { |attr| normalize_attribute(attr) }
|
|
62
|
+
else
|
|
63
|
+
fallback_attributes(model_class, normalized_view)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
def normalize_attribute(attribute)
|
|
70
|
+
case attribute
|
|
71
|
+
when Hash
|
|
72
|
+
attribute.each_with_object({}) do |(key, value), hash|
|
|
73
|
+
hash[key.to_sym] = Array(value).map { |entry| normalize_attribute(entry) }
|
|
74
|
+
end
|
|
75
|
+
else
|
|
76
|
+
attribute.to_sym
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def fallback_attributes(model_class, view)
|
|
81
|
+
case view
|
|
82
|
+
when :collection
|
|
83
|
+
SuperAdmin::ResourceConfiguration
|
|
84
|
+
.displayable_attributes(model_class)
|
|
85
|
+
.map { |attr| attr.to_sym }
|
|
86
|
+
when :show
|
|
87
|
+
SuperAdmin::ResourceConfiguration
|
|
88
|
+
.displayable_attributes(model_class)
|
|
89
|
+
.map { |attr| attr.to_sym }
|
|
90
|
+
when :form
|
|
91
|
+
SuperAdmin::ResourceConfiguration
|
|
92
|
+
.editable_attributes(model_class)
|
|
93
|
+
.map { |attr| attr.is_a?(String) ? attr.to_sym : attr }
|
|
94
|
+
else
|
|
95
|
+
[]
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bigdecimal"
|
|
4
|
+
require "digest"
|
|
5
|
+
|
|
6
|
+
module SuperAdmin
|
|
7
|
+
# Dynamically builds available filters for a SuperAdmin resource
|
|
8
|
+
# and applies associated conditions on an ActiveRecord relation.
|
|
9
|
+
class FilterBuilder
|
|
10
|
+
FilterDefinition = Struct.new(:attribute, :type, :param_keys, :label, :options, keyword_init: true)
|
|
11
|
+
|
|
12
|
+
CACHE_NAMESPACE = "super_admin/filter_definitions".freeze
|
|
13
|
+
CACHE_EXPIRATION = 1.hour
|
|
14
|
+
|
|
15
|
+
STRING_TYPES = %i[string text].freeze
|
|
16
|
+
NUMERIC_TYPES = %i[integer float decimal].freeze
|
|
17
|
+
DATE_TYPES = %i[date datetime].freeze
|
|
18
|
+
BOOLEAN_TYPES = %i[boolean].freeze
|
|
19
|
+
|
|
20
|
+
class << self
|
|
21
|
+
# Returns filter definitions for a given model class.
|
|
22
|
+
# @param model_class [Class]
|
|
23
|
+
# @return [Array<FilterDefinition>]
|
|
24
|
+
def definitions_for(model_class)
|
|
25
|
+
Rails.cache.fetch(cache_key_for(model_class), expires_in: CACHE_EXPIRATION) do
|
|
26
|
+
new(model_class).definitions
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Returns list of permitted parameter keys for filters.
|
|
31
|
+
# @param model_class [Class]
|
|
32
|
+
# @return [Array<Symbol>]
|
|
33
|
+
def permitted_param_keys(model_class)
|
|
34
|
+
definitions_for(model_class).flat_map(&:param_keys).uniq
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Applies filters on an ActiveRecord relation.
|
|
38
|
+
# @param scope [ActiveRecord::Relation]
|
|
39
|
+
# @param model_class [Class]
|
|
40
|
+
# @param params [Hash]
|
|
41
|
+
# @return [ActiveRecord::Relation]
|
|
42
|
+
def apply(scope, model_class, params)
|
|
43
|
+
return scope if params.blank?
|
|
44
|
+
|
|
45
|
+
SuperAdmin::Queries::FilterQuery.new(
|
|
46
|
+
scope,
|
|
47
|
+
model_class,
|
|
48
|
+
filters: params,
|
|
49
|
+
filter_definitions: definitions_for(model_class)
|
|
50
|
+
).call
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def cache_key_for(model_class)
|
|
56
|
+
columns_signature = model_class.columns_hash.values.map do |column|
|
|
57
|
+
[ column.name, column.sql_type, column.default, column.null ]
|
|
58
|
+
end
|
|
59
|
+
enums_signature = model_class.respond_to?(:defined_enums) ? model_class.defined_enums : {}
|
|
60
|
+
digest_source = [ model_class.name, columns_signature, enums_signature ].to_s
|
|
61
|
+
digest = Digest::SHA256.hexdigest(digest_source)
|
|
62
|
+
"#{CACHE_NAMESPACE}/#{model_class.name}/#{digest}"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# DEPRECATED: These methods are kept for compatibility but no longer used.
|
|
66
|
+
# Logic has been moved to SuperAdmin::Queries::FilterQuery
|
|
67
|
+
|
|
68
|
+
def apply_string_filter(scope, arel_table, attribute, value)
|
|
69
|
+
SuperAdmin::Queries::FilterQuery.new(scope, scope.model, filters: { "#{attribute}_contains" => value }).call
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def apply_numeric_filter(scope, arel_table, attribute, type, params)
|
|
73
|
+
SuperAdmin::Queries::FilterQuery.new(scope, scope.model, filters: params).call
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def apply_date_filter(scope, arel_table, attribute, type, params)
|
|
77
|
+
SuperAdmin::Queries::FilterQuery.new(scope, scope.model, filters: params).call
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def apply_boolean_filter(scope, attribute, value)
|
|
81
|
+
SuperAdmin::Queries::FilterQuery.new(scope, scope.model, filters: { "#{attribute}_equals" => value }).call
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def parse_numeric(value, type)
|
|
85
|
+
case type
|
|
86
|
+
when :integer
|
|
87
|
+
Integer(value)
|
|
88
|
+
when :float
|
|
89
|
+
Float(value)
|
|
90
|
+
when :decimal
|
|
91
|
+
BigDecimal(value)
|
|
92
|
+
end
|
|
93
|
+
rescue ArgumentError, TypeError
|
|
94
|
+
nil
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def parse_temporal(value, type)
|
|
98
|
+
case type
|
|
99
|
+
when :date
|
|
100
|
+
Date.parse(value)
|
|
101
|
+
when :datetime
|
|
102
|
+
Time.zone.parse(value)
|
|
103
|
+
end
|
|
104
|
+
rescue ArgumentError, TypeError
|
|
105
|
+
nil
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def parse_boolean(value)
|
|
109
|
+
return true if %w[true 1 yes oui].include?(value.to_s.downcase)
|
|
110
|
+
return false if %w[false 0 no non].include?(value.to_s.downcase)
|
|
111
|
+
|
|
112
|
+
nil
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
attr_reader :model_class
|
|
117
|
+
|
|
118
|
+
def initialize(model_class)
|
|
119
|
+
@model_class = model_class
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Returns computed definitions (memoized)
|
|
123
|
+
# @return [Array<FilterDefinition>]
|
|
124
|
+
def definitions
|
|
125
|
+
@definitions ||= build_definitions
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
private
|
|
129
|
+
|
|
130
|
+
def build_definitions
|
|
131
|
+
enums = enum_attributes
|
|
132
|
+
|
|
133
|
+
model_class.columns.filter_map do |column|
|
|
134
|
+
next if %w[id created_at updated_at].include?(column.name)
|
|
135
|
+
|
|
136
|
+
attribute = column.name
|
|
137
|
+
label = model_class.human_attribute_name(attribute)
|
|
138
|
+
|
|
139
|
+
if enums.key?(attribute)
|
|
140
|
+
FilterDefinition.new(
|
|
141
|
+
attribute: attribute,
|
|
142
|
+
type: :enum,
|
|
143
|
+
param_keys: [ "#{attribute}_equals".to_sym ],
|
|
144
|
+
label: label,
|
|
145
|
+
options: enums[attribute].keys
|
|
146
|
+
)
|
|
147
|
+
elsif STRING_TYPES.include?(column.type)
|
|
148
|
+
FilterDefinition.new(
|
|
149
|
+
attribute: attribute,
|
|
150
|
+
type: column.type,
|
|
151
|
+
param_keys: [ "#{attribute}_contains".to_sym ],
|
|
152
|
+
label: label
|
|
153
|
+
)
|
|
154
|
+
elsif NUMERIC_TYPES.include?(column.type)
|
|
155
|
+
FilterDefinition.new(
|
|
156
|
+
attribute: attribute,
|
|
157
|
+
type: column.type,
|
|
158
|
+
param_keys: [ "#{attribute}_min".to_sym, "#{attribute}_max".to_sym ],
|
|
159
|
+
label: label
|
|
160
|
+
)
|
|
161
|
+
elsif DATE_TYPES.include?(column.type)
|
|
162
|
+
FilterDefinition.new(
|
|
163
|
+
attribute: attribute,
|
|
164
|
+
type: column.type,
|
|
165
|
+
param_keys: [ "#{attribute}_from".to_sym, "#{attribute}_to".to_sym ],
|
|
166
|
+
label: label
|
|
167
|
+
)
|
|
168
|
+
elsif BOOLEAN_TYPES.include?(column.type)
|
|
169
|
+
FilterDefinition.new(
|
|
170
|
+
attribute: attribute,
|
|
171
|
+
type: :boolean,
|
|
172
|
+
param_keys: [ "#{attribute}_equals".to_sym ],
|
|
173
|
+
label: label
|
|
174
|
+
)
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def enum_attributes
|
|
180
|
+
return {} unless model_class.respond_to?(:defined_enums)
|
|
181
|
+
|
|
182
|
+
model_class.defined_enums
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SuperAdmin
|
|
4
|
+
# Service responsible for generating form fields
|
|
5
|
+
# based on attribute type and model associations.
|
|
6
|
+
class FormBuilder
|
|
7
|
+
attr_reader :model_class, :form, :attribute_name, :column, :view_context
|
|
8
|
+
|
|
9
|
+
def initialize(model_class:, form:, attribute_name:)
|
|
10
|
+
@model_class = model_class
|
|
11
|
+
@form = form
|
|
12
|
+
@attribute_name = attribute_name.to_s
|
|
13
|
+
@column = model_class.columns_hash[@attribute_name]
|
|
14
|
+
@view_context = resolve_view_context
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Generates the appropriate form field
|
|
18
|
+
# @return [String] HTML of the form field
|
|
19
|
+
def build_field
|
|
20
|
+
field.render
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Returns the field type
|
|
24
|
+
# @return [Symbol]
|
|
25
|
+
def field_type
|
|
26
|
+
field.type
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Returns options for the field
|
|
30
|
+
# @return [Hash]
|
|
31
|
+
def field_options
|
|
32
|
+
field.options
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Returns the label for the field
|
|
36
|
+
# @return [String]
|
|
37
|
+
def field_label
|
|
38
|
+
field.label
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
protected
|
|
42
|
+
|
|
43
|
+
def field
|
|
44
|
+
@field ||= SuperAdmin::FormFields::Factory.build(self)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def resolve_view_context
|
|
50
|
+
template = form.instance_variable_get(:@template)
|
|
51
|
+
return template if template
|
|
52
|
+
|
|
53
|
+
context = form.instance_variable_get(:@view_context)
|
|
54
|
+
return context if context
|
|
55
|
+
|
|
56
|
+
raise ArgumentError, "SuperAdmin::FormBuilder requires an ActionView context"
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SuperAdmin
|
|
4
|
+
module FormFields
|
|
5
|
+
# Renders a textarea optimized for array-backed attributes.
|
|
6
|
+
class ArrayField < TextAreaField
|
|
7
|
+
def render
|
|
8
|
+
form.text_area(attribute_name, options.merge(value: formatted_value))
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def type
|
|
12
|
+
:array
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
private
|
|
16
|
+
|
|
17
|
+
def formatted_value
|
|
18
|
+
value = form.object.public_send(attribute_name)
|
|
19
|
+
return "" if value.blank?
|
|
20
|
+
|
|
21
|
+
Array(value).map(&:to_s).join("\n")
|
|
22
|
+
rescue NoMethodError
|
|
23
|
+
""
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def options
|
|
27
|
+
base_options.merge(rows: 4, placeholder: placeholder_text)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def placeholder_text
|
|
31
|
+
I18n.t("super_admin.resources.form.array_placeholder", default: "Enter one value per line or separate with commas")
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SuperAdmin
|
|
4
|
+
module FormFields
|
|
5
|
+
class AssociationField < BaseField
|
|
6
|
+
def render
|
|
7
|
+
return safe_text_field(attribute_name) unless association
|
|
8
|
+
|
|
9
|
+
total_count = associated_class.count
|
|
10
|
+
records = limited_records
|
|
11
|
+
|
|
12
|
+
if total_count > SuperAdmin.association_select_limit
|
|
13
|
+
searchable_select(records, total_count)
|
|
14
|
+
else
|
|
15
|
+
standard_select(records)
|
|
16
|
+
end
|
|
17
|
+
rescue StandardError => e
|
|
18
|
+
Rails.logger.error("SuperAdmin::FormBuilder - Association field error: #{e.message}")
|
|
19
|
+
form.text_field(attribute_name, base_options)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def type
|
|
23
|
+
:association
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def options
|
|
27
|
+
base_options.except(:required)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def association
|
|
33
|
+
@association ||= model_class.reflect_on_association(association_name)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def association_name
|
|
37
|
+
attribute_name.to_s.delete_suffix("_id").to_sym
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def associated_class
|
|
41
|
+
association.klass
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def limited_records
|
|
45
|
+
fetch_records
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def fetch_records
|
|
49
|
+
limit = SuperAdmin.association_select_limit
|
|
50
|
+
|
|
51
|
+
if associated_class.column_names.include?("name")
|
|
52
|
+
associated_class.order(:name).limit(limit)
|
|
53
|
+
elsif associated_class.column_names.include?("title")
|
|
54
|
+
associated_class.order(:title).limit(limit)
|
|
55
|
+
elsif associated_class.column_names.include?("created_at")
|
|
56
|
+
associated_class.order(created_at: :desc).limit(limit)
|
|
57
|
+
else
|
|
58
|
+
associated_class.limit(limit)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def standard_select(records)
|
|
63
|
+
render_select(records, options)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def searchable_select(records, total_count)
|
|
67
|
+
limited_count = records.size
|
|
68
|
+
selected_record = selected_record_for(records)
|
|
69
|
+
|
|
70
|
+
records_for_select = records.to_a
|
|
71
|
+
records_for_select.unshift(selected_record) if selected_record && !records_for_select.include?(selected_record)
|
|
72
|
+
|
|
73
|
+
select = render_select(
|
|
74
|
+
records_for_select,
|
|
75
|
+
options.merge(
|
|
76
|
+
class: "#{options[:class]} pr-10",
|
|
77
|
+
data: {
|
|
78
|
+
controller: "super-admin--association-select",
|
|
79
|
+
super_admin__association_select_target: "select",
|
|
80
|
+
association: association_name,
|
|
81
|
+
searchable: "true",
|
|
82
|
+
total_count: total_count
|
|
83
|
+
}
|
|
84
|
+
)
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
hint = view_context.content_tag(
|
|
88
|
+
:p,
|
|
89
|
+
view_context.t("super_admin.resources.form.association_limited", count: limited_count, total: total_count),
|
|
90
|
+
class: "mt-1 text-xs text-gray-500"
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
view_context.safe_join([ select, hint ])
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def skip_required?
|
|
97
|
+
true
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def safe_text_field(attr)
|
|
101
|
+
if form.object && form.object.respond_to?(attr)
|
|
102
|
+
form.text_field(attr, base_options)
|
|
103
|
+
else
|
|
104
|
+
view_context.text_field_tag(attr, nil, base_options)
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def render_select(records, html_options)
|
|
109
|
+
include_blank = column&.null
|
|
110
|
+
selected_value = current_attribute_value
|
|
111
|
+
|
|
112
|
+
if form.object && form.object.respond_to?(attribute_name)
|
|
113
|
+
form.collection_select(
|
|
114
|
+
attribute_name,
|
|
115
|
+
records,
|
|
116
|
+
:id,
|
|
117
|
+
:to_s,
|
|
118
|
+
{ include_blank: include_blank, selected: selected_value },
|
|
119
|
+
html_options
|
|
120
|
+
)
|
|
121
|
+
else
|
|
122
|
+
option_tags = view_context.options_from_collection_for_select(records, :id, :to_s, selected_value)
|
|
123
|
+
option_tags = view_context.tag.option("", value: "") + option_tags if include_blank
|
|
124
|
+
view_context.select_tag(attribute_name, option_tags, html_options)
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def current_attribute_value
|
|
129
|
+
if form.object && form.object.respond_to?(attribute_name)
|
|
130
|
+
form.object.public_send(attribute_name)
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def selected_record_for(records)
|
|
135
|
+
value = current_attribute_value
|
|
136
|
+
return unless value
|
|
137
|
+
|
|
138
|
+
if records.respond_to?(:find) && records.respond_to?(:detect)
|
|
139
|
+
records.detect { |record| record.id == value } || associated_class.find_by(id: value)
|
|
140
|
+
else
|
|
141
|
+
associated_class.find_by(id: value)
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SuperAdmin
|
|
4
|
+
module FormFields
|
|
5
|
+
# Base class for SuperAdmin form fields.
|
|
6
|
+
class BaseField
|
|
7
|
+
attr_reader :builder
|
|
8
|
+
|
|
9
|
+
delegate :form, :model_class, :attribute_name, :column, :view_context, to: :builder
|
|
10
|
+
|
|
11
|
+
def initialize(builder)
|
|
12
|
+
@builder = builder
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def render
|
|
16
|
+
form.text_field(attribute_name, options)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def type
|
|
20
|
+
:text
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def options
|
|
24
|
+
base_options
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def label
|
|
28
|
+
model_class.human_attribute_name(attribute_name)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
protected
|
|
32
|
+
|
|
33
|
+
def base_options
|
|
34
|
+
@base_options ||= begin
|
|
35
|
+
input_options = { class: input_css_class }
|
|
36
|
+
if column
|
|
37
|
+
input_options[:required] = !column.null unless skip_required?
|
|
38
|
+
input_options[:maxlength] = column.limit if column.limit
|
|
39
|
+
end
|
|
40
|
+
input_options
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def input_css_class
|
|
45
|
+
"block w-full rounded-md border-2 border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm px-3 py-2"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def skip_required?
|
|
49
|
+
%w[created_at updated_at id].include?(attribute_name.to_s)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SuperAdmin
|
|
4
|
+
module FormFields
|
|
5
|
+
class BooleanField < BaseField
|
|
6
|
+
def render
|
|
7
|
+
form.check_box(attribute_name, class: input_css_class)
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def type
|
|
11
|
+
:boolean
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def options
|
|
15
|
+
{ class: input_css_class }
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
def input_css_class
|
|
21
|
+
"h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def skip_required?
|
|
25
|
+
true
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|