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.
Files changed (100) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +216 -0
  3. data/Rakefile +30 -0
  4. data/app/assets/stylesheets/super_admin/application.css +15 -0
  5. data/app/assets/stylesheets/super_admin/tailwind.css +1 -0
  6. data/app/assets/stylesheets/super_admin/tailwind.source.css +25 -0
  7. data/app/controllers/super_admin/application_controller.rb +89 -0
  8. data/app/controllers/super_admin/associations_controller.rb +136 -0
  9. data/app/controllers/super_admin/audit_logs_controller.rb +39 -0
  10. data/app/controllers/super_admin/base_controller.rb +133 -0
  11. data/app/controllers/super_admin/dashboard_controller.rb +29 -0
  12. data/app/controllers/super_admin/exports_controller.rb +109 -0
  13. data/app/controllers/super_admin/resources_controller.rb +201 -0
  14. data/app/dashboards/super_admin/base_dashboard.rb +200 -0
  15. data/app/errors/super_admin/configuration_error.rb +6 -0
  16. data/app/helpers/super_admin/application_helper.rb +84 -0
  17. data/app/helpers/super_admin/exports_helper.rb +16 -0
  18. data/app/helpers/super_admin/resources_helper.rb +204 -0
  19. data/app/helpers/super_admin/route_helper.rb +7 -0
  20. data/app/javascript/super_admin/application.js +263 -0
  21. data/app/jobs/super_admin/application_job.rb +4 -0
  22. data/app/jobs/super_admin/generate_super_admin_csv_export_job.rb +100 -0
  23. data/app/mailers/super_admin/application_mailer.rb +6 -0
  24. data/app/models/super_admin/application_record.rb +5 -0
  25. data/app/models/super_admin/audit_log.rb +35 -0
  26. data/app/models/super_admin/csv_export.rb +67 -0
  27. data/app/services/super_admin/auditing.rb +74 -0
  28. data/app/services/super_admin/authorization.rb +113 -0
  29. data/app/services/super_admin/authorization_adapters/base_adapter.rb +100 -0
  30. data/app/services/super_admin/authorization_adapters/default_adapter.rb +77 -0
  31. data/app/services/super_admin/authorization_adapters/proc_adapter.rb +65 -0
  32. data/app/services/super_admin/authorization_adapters/pundit_adapter.rb +81 -0
  33. data/app/services/super_admin/csv_export_creator.rb +45 -0
  34. data/app/services/super_admin/dashboard_registry.rb +90 -0
  35. data/app/services/super_admin/dashboard_resolver.rb +100 -0
  36. data/app/services/super_admin/filter_builder.rb +185 -0
  37. data/app/services/super_admin/form_builder.rb +59 -0
  38. data/app/services/super_admin/form_fields/array_field.rb +35 -0
  39. data/app/services/super_admin/form_fields/association_field.rb +146 -0
  40. data/app/services/super_admin/form_fields/base_field.rb +53 -0
  41. data/app/services/super_admin/form_fields/boolean_field.rb +29 -0
  42. data/app/services/super_admin/form_fields/date_field.rb +15 -0
  43. data/app/services/super_admin/form_fields/date_time_field.rb +15 -0
  44. data/app/services/super_admin/form_fields/enum_field.rb +27 -0
  45. data/app/services/super_admin/form_fields/factory.rb +102 -0
  46. data/app/services/super_admin/form_fields/nested_field.rb +120 -0
  47. data/app/services/super_admin/form_fields/number_field.rb +29 -0
  48. data/app/services/super_admin/form_fields/text_area_field.rb +19 -0
  49. data/app/services/super_admin/model_inspector.rb +182 -0
  50. data/app/services/super_admin/queries/base_query.rb +45 -0
  51. data/app/services/super_admin/queries/filter_query.rb +188 -0
  52. data/app/services/super_admin/queries/resource_scope_query.rb +74 -0
  53. data/app/services/super_admin/queries/search_query.rb +146 -0
  54. data/app/services/super_admin/queries/sort_query.rb +41 -0
  55. data/app/services/super_admin/resource_configuration.rb +63 -0
  56. data/app/services/super_admin/resource_exporter.rb +78 -0
  57. data/app/services/super_admin/resource_query.rb +40 -0
  58. data/app/services/super_admin/resources/association_inspector.rb +112 -0
  59. data/app/services/super_admin/resources/collection_presenter.rb +63 -0
  60. data/app/services/super_admin/resources/context.rb +63 -0
  61. data/app/services/super_admin/resources/filter_params.rb +29 -0
  62. data/app/services/super_admin/resources/permitted_attributes.rb +104 -0
  63. data/app/services/super_admin/resources/value_normalizer.rb +121 -0
  64. data/app/services/super_admin/sensitive_attributes.rb +166 -0
  65. data/app/views/layouts/super_admin.html.erb +74 -0
  66. data/app/views/super_admin/audit_logs/index.html.erb +143 -0
  67. data/app/views/super_admin/dashboard/index.html.erb +79 -0
  68. data/app/views/super_admin/exports/index.html.erb +84 -0
  69. data/app/views/super_admin/exports/show.html.erb +57 -0
  70. data/app/views/super_admin/resources/_form.html.erb +42 -0
  71. data/app/views/super_admin/resources/destroy.turbo_stream.erb +17 -0
  72. data/app/views/super_admin/resources/edit.html.erb +37 -0
  73. data/app/views/super_admin/resources/index.html.erb +189 -0
  74. data/app/views/super_admin/resources/new.html.erb +31 -0
  75. data/app/views/super_admin/resources/show.html.erb +106 -0
  76. data/app/views/super_admin/shared/_breadcrumbs.html.erb +12 -0
  77. data/app/views/super_admin/shared/_custom_styles.html.erb +132 -0
  78. data/app/views/super_admin/shared/_flash.html.erb +55 -0
  79. data/app/views/super_admin/shared/_form_field.html.erb +35 -0
  80. data/app/views/super_admin/shared/_navigation.html.erb +92 -0
  81. data/app/views/super_admin/shared/_nested_fields.html.erb +59 -0
  82. data/app/views/super_admin/shared/_nested_record_fields.html.erb +45 -0
  83. data/config/importmap.rb +4 -0
  84. data/config/initializers/rack_attack.rb +134 -0
  85. data/config/initializers/super_admin.rb +117 -0
  86. data/config/locales/super_admin.en.yml +197 -0
  87. data/config/locales/super_admin.fr.yml +197 -0
  88. data/config/routes.rb +22 -0
  89. data/lib/generators/super_admin/dashboard_generator.rb +50 -0
  90. data/lib/generators/super_admin/install_generator.rb +58 -0
  91. data/lib/generators/super_admin/templates/20240101000001_create_super_admin_audit_logs.rb +24 -0
  92. data/lib/generators/super_admin/templates/20240101000002_create_super_admin_csv_exports.rb +33 -0
  93. data/lib/generators/super_admin/templates/super_admin.rb +58 -0
  94. data/lib/super_admin/dashboard_creator.rb +256 -0
  95. data/lib/super_admin/engine.rb +53 -0
  96. data/lib/super_admin/install_task.rb +96 -0
  97. data/lib/super_admin/version.rb +3 -0
  98. data/lib/super_admin.rb +7 -0
  99. data/lib/tasks/super_admin_tasks.rake +38 -0
  100. 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