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,133 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ostruct"
4
+
5
+ module SuperAdmin
6
+ # Base controller for all SuperAdmin controllers.
7
+ # Handles authentication, authorization, and custom layout.
8
+ class BaseController < SuperAdmin::ApplicationController
9
+ before_action :authorize_super_admin!
10
+
11
+ private
12
+
13
+ # Pagy helper method for pagination
14
+ # Returns a simple pagination object and the paginated collection
15
+ def pagy(collection, **vars)
16
+ # Set default items per page
17
+ items_per_page = vars.delete(:limit) || 25
18
+
19
+ # Get current page from params
20
+ page = (params[:page] || 1).to_i
21
+ page = 1 if page < 1
22
+
23
+ # Calculate total count
24
+ count = collection.count
25
+
26
+ # Calculate pagination values
27
+ pages = (count.to_f / items_per_page).ceil
28
+ pages = 1 if pages < 1
29
+ page = pages if page > pages
30
+
31
+ offset = (page - 1) * items_per_page
32
+
33
+ # Create a simple pagination object
34
+ pagy_obj = OpenStruct.new(
35
+ count: count,
36
+ page: page,
37
+ items: items_per_page,
38
+ pages: pages,
39
+ offset: offset,
40
+ limit: items_per_page,
41
+ next: (page < pages ? page + 1 : nil),
42
+ prev: (page > 1 ? page - 1 : nil),
43
+ in: [ [ (offset + 1), count ].min, [ offset + items_per_page, count ].min ]
44
+ )
45
+
46
+ # Return paginated collection
47
+ [ pagy_obj, collection.offset(offset).limit(items_per_page) ]
48
+ end
49
+
50
+ def authorize_super_admin!
51
+ return if SuperAdmin::Authorization.call(self)
52
+
53
+ # Si call retourne false, l'adapter a déjà géré la réponse (redirect/render).
54
+ # On stoppe la suite du traitement via la primitive Rack disponible.
55
+ if request.respond_to?(:halt)
56
+ request.halt
57
+ end
58
+
59
+ false
60
+ end
61
+
62
+ def available_models
63
+ @available_models ||= SuperAdmin::ModelInspector.all_models
64
+ end
65
+ helper_method :available_models
66
+
67
+ def model_display_name(model_class)
68
+ model_class.model_name.human(count: 2)
69
+ end
70
+ helper_method :model_display_name
71
+
72
+ def model_path(model_class)
73
+ SuperAdmin::Engine.routes.url_helpers.resources_path(resource: model_class.name.underscore.pluralize)
74
+ end
75
+ helper_method :model_path
76
+
77
+ # Helper methods for routes that work in both standalone and mounted engine contexts
78
+ def super_admin_root_path
79
+ SuperAdmin::Engine.routes.url_helpers.root_path
80
+ end
81
+ helper_method :super_admin_root_path
82
+
83
+ def super_admin_exports_path
84
+ SuperAdmin::Engine.routes.url_helpers.exports_path
85
+ end
86
+ helper_method :super_admin_exports_path
87
+
88
+ def super_admin_audit_logs_path
89
+ SuperAdmin::Engine.routes.url_helpers.audit_logs_path
90
+ end
91
+ helper_method :super_admin_audit_logs_path
92
+
93
+ def super_admin_export_path(token)
94
+ SuperAdmin::Engine.routes.url_helpers.export_path(token)
95
+ end
96
+ helper_method :super_admin_export_path
97
+
98
+ def download_super_admin_export_path(token)
99
+ SuperAdmin::Engine.routes.url_helpers.download_export_path(token)
100
+ end
101
+ helper_method :download_super_admin_export_path
102
+
103
+ def super_admin_resources_path(options = {})
104
+ SuperAdmin::Engine.routes.url_helpers.resources_path(options)
105
+ end
106
+ helper_method :super_admin_resources_path
107
+
108
+ def super_admin_resource_path(options = {})
109
+ SuperAdmin::Engine.routes.url_helpers.resource_path(options)
110
+ end
111
+ helper_method :super_admin_resource_path
112
+
113
+ def super_admin_new_resource_path(options = {})
114
+ SuperAdmin::Engine.routes.url_helpers.new_resource_path(options)
115
+ end
116
+ helper_method :super_admin_new_resource_path
117
+
118
+ def super_admin_edit_resource_path(options = {})
119
+ SuperAdmin::Engine.routes.url_helpers.edit_resource_path(options)
120
+ end
121
+ helper_method :super_admin_edit_resource_path
122
+
123
+ def super_admin_bulk_action_path(options = {})
124
+ SuperAdmin::Engine.routes.url_helpers.bulk_action_path(options)
125
+ end
126
+ helper_method :super_admin_bulk_action_path
127
+
128
+ def super_admin_association_search_path(options = {})
129
+ SuperAdmin::Engine.routes.url_helpers.association_search_path(options)
130
+ end
131
+ helper_method :super_admin_association_search_path
132
+ end
133
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SuperAdmin
4
+ # SuperAdmin dashboard controller displaying all administrable models.
5
+ class DashboardController < SuperAdmin::BaseController
6
+ def index
7
+ @models_info = available_models.map do |model_class|
8
+ begin
9
+ count = model_class.count
10
+ rescue ActiveRecord::StatementInvalid, StandardError => e
11
+ Rails.logger.warn("Cannot count #{model_class.name}: #{e.message}")
12
+ count = 0
13
+ end
14
+
15
+ {
16
+ class: model_class,
17
+ name: model_class.name,
18
+ human_name: model_display_name(model_class),
19
+ count: count,
20
+ table_name: model_class.table_name,
21
+ path: model_path(model_class)
22
+ }
23
+ rescue StandardError => e
24
+ Rails.logger.error("Error inspecting model #{model_class.name}: #{e.message}")
25
+ nil
26
+ end.compact
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SuperAdmin
4
+ # Handles creation and tracking of SuperAdmin CSV exports.
5
+ class ExportsController < SuperAdmin::BaseController
6
+ helper SuperAdmin::ExportsHelper
7
+
8
+ before_action :load_model_class, only: :create
9
+ before_action :find_export, only: %i[show download destroy]
10
+
11
+ def index
12
+ exports_scope = current_user.csv_exports.recent_first
13
+ exports_scope = exports_scope.includes(:file_attachment) if active_storage_available?
14
+ @pagy, @exports = pagy(exports_scope, limit: 20)
15
+ end
16
+
17
+ def show
18
+ nil if performed?
19
+ end
20
+
21
+ def create
22
+ return if performed?
23
+
24
+ filters = sanitized_filters(@model_class, params[:filters])
25
+
26
+ export = SuperAdmin::CsvExportCreator.call(
27
+ user: current_user,
28
+ model_class: @model_class,
29
+ resource: params[:resource],
30
+ search: params[:search],
31
+ sort: params[:sort],
32
+ direction: params[:direction],
33
+ filters: filters,
34
+ attributes: SuperAdmin::DashboardResolver.collection_attributes_for(@model_class)
35
+ )
36
+
37
+ redirect_to super_admin_exports_path,
38
+ notice: t("super_admin.exports.flash.created", token: export.token)
39
+ end
40
+
41
+ def download
42
+ return unless @export
43
+ unless @export.ready_for_download?
44
+ redirect_to super_admin_exports_path,
45
+ alert: t("super_admin.exports.flash.unavailable")
46
+ return
47
+ end
48
+
49
+ if @export.expires_at.present? && @export.expires_at.past?
50
+ redirect_to super_admin_exports_path,
51
+ alert: t("super_admin.exports.flash.expired")
52
+ return
53
+ end
54
+
55
+ send_data @export.file.download,
56
+ filename: @export.file.filename.to_s,
57
+ type: @export.file.content_type || "text/csv",
58
+ disposition: "attachment"
59
+ end
60
+
61
+ def destroy
62
+ return unless @export
63
+
64
+ if active_storage_available?
65
+ @export.destroy!
66
+ else
67
+ @export.delete
68
+ end
69
+
70
+ redirect_to super_admin_exports_path,
71
+ notice: t("super_admin.exports.flash.destroyed")
72
+ end
73
+
74
+ private
75
+
76
+ def load_model_class
77
+ @model_class = SuperAdmin::ModelInspector.find_model(params[:resource])
78
+
79
+ return if @model_class
80
+
81
+ redirect_to super_admin_root_path,
82
+ alert: t("super_admin.resources.flash.load_model_failed", resource: params[:resource])
83
+ end
84
+
85
+ def sanitized_filters(model_class, raw_filters)
86
+ return {} if raw_filters.blank?
87
+
88
+ filters_params = raw_filters.is_a?(ActionController::Parameters) ? raw_filters : ActionController::Parameters.new(raw_filters)
89
+ permitted_keys = SuperAdmin::FilterBuilder.permitted_param_keys(model_class)
90
+ filters_params.permit(*permitted_keys).to_unsafe_h
91
+ end
92
+
93
+ def find_export
94
+ @export = current_user.csv_exports.find_by(token: params[:token])
95
+ return if @export
96
+
97
+ redirect_to super_admin_exports_path,
98
+ alert: t("super_admin.exports.flash.not_found")
99
+ end
100
+
101
+ def active_storage_available?
102
+ return false unless defined?(ActiveStorage::Attachment)
103
+
104
+ ActiveStorage::Attachment.table_exists?
105
+ rescue ActiveRecord::StatementInvalid, ActiveRecord::NoDatabaseError
106
+ false
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,201 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SuperAdmin
4
+ # Generic CRUD controller for all resources.
5
+ # Uses ActiveRecord reflection to adapt dynamically to each model.
6
+ class ResourcesController < SuperAdmin::BaseController
7
+ before_action :load_resource_context
8
+ before_action :set_resource, only: %i[show edit update destroy]
9
+
10
+ # GET /super_admin/:resource
11
+ def index
12
+ collection = SuperAdmin::Resources::CollectionPresenter.new(context: @context, params: params)
13
+ @filter_definitions = collection.filter_definitions
14
+ @attributes = @context.displayable_attributes
15
+
16
+ respond_to do |format|
17
+ format.html do
18
+ @applied_filters = collection.filter_params
19
+ @pagy, @resources = pagy(collection.scope, limit: 25)
20
+ end
21
+
22
+ format.csv do
23
+ export = collection.queue_export!(current_user, @attributes)
24
+
25
+ flash[:notice] = t("super_admin.exports.flash.created", token: export.token)
26
+ redirect_to super_admin_exports_path
27
+ end
28
+ end
29
+ end
30
+
31
+ # GET /super_admin/:resource/:id
32
+ def show
33
+ @attributes = @context.show_attributes
34
+ @associations = @model_class.reflect_on_all_associations
35
+ @association_counts = SuperAdmin::Resources::AssociationInspector.new(@resource).has_many_counts(@associations)
36
+ end
37
+
38
+ # GET /super_admin/:resource/new
39
+ def new
40
+ @resource = @model_class.new
41
+ @editable_attributes = @context.editable_attributes
42
+ end
43
+
44
+ # POST /super_admin/:resource
45
+ def create
46
+ @resource = @model_class.new(resource_params)
47
+
48
+ if @resource.save
49
+ audit(:create, resource: @resource)
50
+ redirect_to super_admin_resource_path(resource: params[:resource], id: @resource.id),
51
+ notice: t("super_admin.resources.flash.create.success", model: @model_class.model_name.human)
52
+ else
53
+ @editable_attributes = @context.editable_attributes
54
+ render :new, status: :unprocessable_entity
55
+ end
56
+ end
57
+
58
+ # GET /super_admin/:resource/:id/edit
59
+ def edit
60
+ @editable_attributes = @context.editable_attributes
61
+ end
62
+
63
+ # PATCH /super_admin/:resource/:id
64
+ def update
65
+ if @resource.update(resource_params)
66
+ audit(:update, resource: @resource, changes: @resource.previous_changes)
67
+ redirect_to super_admin_resource_path(resource: params[:resource], id: @resource.id),
68
+ notice: t("super_admin.resources.flash.update.success", model: @model_class.model_name.human)
69
+ else
70
+ @editable_attributes = @context.editable_attributes
71
+ render :edit, status: :unprocessable_entity
72
+ end
73
+ end
74
+
75
+ # DELETE /super_admin/:resource/:id
76
+ def destroy
77
+ snapshot = @resource.attributes
78
+ @resource.destroy!
79
+ audit(
80
+ :destroy,
81
+ resource_type: @model_class.name,
82
+ resource_id: snapshot[@model_class.primary_key]&.to_s,
83
+ changes: { "before" => snapshot }
84
+ )
85
+
86
+ redirect_to super_admin_resources_path(resource: params[:resource]),
87
+ notice: t("super_admin.resources.flash.destroy.success", model: @model_class.model_name.human)
88
+ rescue ActiveRecord::InvalidForeignKey, ActiveRecord::DeleteRestrictionError
89
+ redirect_to super_admin_resource_path(resource: params[:resource], id: @resource.id),
90
+ alert: t("super_admin.resources.flash.destroy.dependencies")
91
+ end
92
+
93
+ # POST /super_admin/:resource/bulk
94
+ def bulk
95
+ collection = SuperAdmin::Resources::CollectionPresenter.new(context: @context, params: params)
96
+ selection = bulk_params[:resource_ids]&.reject(&:blank?) || []
97
+ action = bulk_params[:bulk_action]
98
+
99
+ if selection.empty?
100
+ redirect_to super_admin_resources_path(resource: params[:resource], **collection.preserved_params),
101
+ alert: t("super_admin.resources.flash.bulk.selection_required")
102
+ return
103
+ end
104
+
105
+ case action
106
+ when "destroy"
107
+ records = @model_class.where(id: selection)
108
+ destroyed_count = records.size
109
+ @model_class.transaction do
110
+ records.find_each do |record|
111
+ snapshot = record.attributes
112
+ record.destroy!
113
+ audit(
114
+ :destroy,
115
+ resource_type: @model_class.name,
116
+ resource_id: snapshot[@model_class.primary_key]&.to_s,
117
+ changes: { "before" => snapshot, "bulk" => true, "selection" => selection }
118
+ )
119
+ end
120
+ end
121
+ redirect_to super_admin_resources_path(resource: params[:resource], **collection.preserved_params),
122
+ notice: t("super_admin.resources.flash.bulk.success", count: destroyed_count, model: @model_class.model_name.human(count: destroyed_count))
123
+ else
124
+ redirect_to super_admin_resources_path(resource: params[:resource], **collection.preserved_params),
125
+ alert: t("super_admin.resources.flash.bulk.unsupported_action")
126
+ end
127
+ rescue ActiveRecord::InvalidForeignKey, ActiveRecord::DeleteRestrictionError
128
+ redirect_to super_admin_resources_path(resource: params[:resource], **collection.preserved_params),
129
+ alert: t("super_admin.resources.flash.bulk.dependencies")
130
+ end
131
+
132
+ private
133
+
134
+ def load_resource_context
135
+ @context = SuperAdmin::Resources::Context.new(params[:resource])
136
+ @model_class = @context.model_class
137
+
138
+ return if @context.valid?
139
+
140
+ flash[:alert] = t("super_admin.resources.flash.load_model_failed", resource: params[:resource])
141
+ redirect_to super_admin_root_path
142
+ request.halt if request.respond_to?(:halt)
143
+ end
144
+
145
+ def set_resource
146
+ relation = @model_class
147
+ if action_name == "show"
148
+ # Use dashboard-configured includes or fall back to AssociationInspector
149
+ includes = SuperAdmin::DashboardResolver.show_includes_for(@model_class)
150
+ if includes.empty?
151
+ includes = SuperAdmin::Resources::AssociationInspector.preloadable_names(@model_class)
152
+ end
153
+ relation = relation.includes(includes) if includes.any?
154
+ end
155
+
156
+ @resource = relation.find(params[:id])
157
+ rescue ActiveRecord::RecordNotFound
158
+ flash[:alert] = t("super_admin.resources.flash.not_found", model: @model_class.model_name.human)
159
+ redirect_to super_admin_resources_path(resource: params[:resource])
160
+ end
161
+
162
+ def bulk_params
163
+ params.permit(:bulk_action, resource_ids: [])
164
+ end
165
+
166
+ # Dynamic strong parameters based on model attributes and nested attributes
167
+ def resource_params
168
+ permitted = SuperAdmin::Resources::PermittedAttributes.new(@model_class).permit(params)
169
+ SuperAdmin::Resources::ValueNormalizer.new(@model_class, permitted).normalize
170
+ end
171
+
172
+ def audit(action, resource: nil, resource_type: nil, resource_id: nil, changes: nil)
173
+ return unless auditable?
174
+
175
+ SuperAdmin::Auditing.log!(
176
+ user: current_user,
177
+ resource: resource,
178
+ resource_type: resource_type,
179
+ resource_id: resource_id,
180
+ action: action,
181
+ changes: changes,
182
+ context: audit_context
183
+ )
184
+ end
185
+
186
+ def auditable?
187
+ @model_class.present? && @model_class.name != "SuperAdmin::AuditLog"
188
+ end
189
+
190
+ def audit_context
191
+ {
192
+ "resource" => params[:resource],
193
+ "resource_id" => params[:id],
194
+ "request_path" => request.fullpath,
195
+ "request_id" => request.request_id,
196
+ "controller" => controller_name,
197
+ "action" => action_name
198
+ }.compact
199
+ end
200
+ end
201
+ end
@@ -0,0 +1,200 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SuperAdmin
4
+ # Base DSL to configure how resources are displayed inside SuperAdmin.
5
+ # Inspired by Administrate dashboards but focused on selecting visible attributes.
6
+ class BaseDashboard
7
+ class_attribute :_resource_class, instance_writer: false
8
+ class_attribute :_collection_attributes, default: nil, instance_writer: false
9
+ class_attribute :_show_attributes, default: nil, instance_writer: false
10
+ class_attribute :_form_attributes, default: nil, instance_writer: false
11
+ class_attribute :_collection_includes, default: nil, instance_writer: false
12
+ class_attribute :_show_includes, default: nil, instance_writer: false
13
+
14
+ class << self
15
+ # Explicitly sets the resource class managed by this dashboard.
16
+ # When omitted, the class is inferred from the dashboard name (e.g. UserDashboard => User).
17
+ def resource(model_class)
18
+ self._resource_class = model_class
19
+ end
20
+
21
+ # Defines attributes shown on the collection (index) page.
22
+ # Usage: collection_attributes :id, :name, :status
23
+ def collection_attributes(*attrs)
24
+ self._collection_attributes = normalize_flat_attribute_list(attrs)
25
+ end
26
+
27
+ # Defines attributes shown on the resource detail (show) page.
28
+ def show_attributes(*attrs)
29
+ self._show_attributes = normalize_flat_attribute_list(attrs)
30
+ end
31
+
32
+ # Defines attributes shown in resource forms (new/edit).
33
+ def form_attributes(*attrs)
34
+ self._form_attributes = normalize_flat_attribute_list(attrs)
35
+ end
36
+
37
+ # Defines associations to preload for the collection (index) page to avoid N+1 queries.
38
+ # Usage: collection_includes :author, :tags, comments: :user
39
+ def collection_includes(*associations)
40
+ self._collection_includes = associations.freeze
41
+ end
42
+
43
+ # Defines associations to preload for the show page to avoid N+1 queries.
44
+ # Usage: show_includes :author, :tags, comments: :user
45
+ def show_includes(*associations)
46
+ self._show_includes = associations.freeze
47
+ end
48
+
49
+ # Returns the ActiveRecord class associated with the dashboard.
50
+ def resource_class
51
+ resolved_resource_class || infer_resource_class
52
+ end
53
+
54
+ # Returns attributes configured for a given view.
55
+ def attributes_for(view)
56
+ case view.to_sym
57
+ when :index, :collection, :list
58
+ collection_attributes_list
59
+ when :show, :detail
60
+ show_attributes_list
61
+ when :form, :new, :edit
62
+ form_attributes_list
63
+ else
64
+ []
65
+ end
66
+ end
67
+
68
+ # Returns configured collection attributes or sensible defaults.
69
+ def collection_attributes_list
70
+ _collection_attributes || default_collection_attributes
71
+ end
72
+
73
+ # Returns configured show attributes or sensible defaults.
74
+ def show_attributes_list
75
+ _show_attributes || default_show_attributes
76
+ end
77
+
78
+ # Returns configured form attributes or sensible defaults.
79
+ def form_attributes_list
80
+ _form_attributes || default_form_attributes
81
+ end
82
+
83
+ # Returns associations to preload for collection view
84
+ def collection_includes_list
85
+ _collection_includes || default_collection_includes
86
+ end
87
+
88
+ # Returns associations to preload for show view
89
+ def show_includes_list
90
+ _show_includes || default_show_includes
91
+ end
92
+
93
+ private
94
+
95
+ def resolved_resource_class
96
+ case _resource_class
97
+ when Class
98
+ _resource_class
99
+ when String, Symbol
100
+ _resource_class.to_s.constantize
101
+ else
102
+ nil
103
+ end
104
+ rescue NameError
105
+ nil
106
+ end
107
+
108
+ def normalize_attribute_list(attrs)
109
+ Array(attrs).flatten.compact.map do |attribute|
110
+ normalize_attribute_entry(attribute)
111
+ end.freeze
112
+ end
113
+
114
+ def normalize_flat_attribute_list(attrs)
115
+ normalize_attribute_list(attrs).flat_map do |entry|
116
+ entry.is_a?(Hash) ? entry.keys.map(&:to_sym) : entry
117
+ end.map(&:to_sym).freeze
118
+ end
119
+
120
+ def normalize_attribute_entry(attribute)
121
+ case attribute
122
+ when Hash
123
+ attribute.each_with_object({}) do |(key, value), hash|
124
+ hash[key.to_sym] = Array(value).flatten.compact.map { |entry| normalize_attribute_entry(entry) }
125
+ end
126
+ else
127
+ attribute.to_sym
128
+ end
129
+ end
130
+
131
+ def infer_resource_class
132
+ name.delete_suffix("Dashboard").constantize
133
+ rescue NameError
134
+ nil
135
+ end
136
+
137
+ def default_collection_attributes
138
+ default_displayable_attributes
139
+ end
140
+
141
+ def default_show_attributes
142
+ default_displayable_attributes
143
+ end
144
+
145
+ def default_form_attributes
146
+ resource = resource_class
147
+ return [] unless resource
148
+
149
+ editable = SuperAdmin::ResourceConfiguration.editable_attributes(resource)
150
+ editable.map { |attr| attr.to_sym }
151
+ end
152
+
153
+ def default_displayable_attributes
154
+ resource = resource_class
155
+ return [] unless resource
156
+
157
+ attrs = SuperAdmin::ResourceConfiguration.displayable_attributes(resource)
158
+ attrs.map { |attr| attr.to_sym }
159
+ end
160
+
161
+ def default_collection_includes
162
+ resource = resource_class
163
+ return [] unless resource
164
+
165
+ # Auto-detect preloadable associations explicitly listed in the collection attributes
166
+ associations_from_attributes(collection_attributes_list, resource)
167
+ end
168
+
169
+ def default_show_includes
170
+ resource = resource_class
171
+ return [] unless resource
172
+
173
+ # Auto-detect belongs_to and has_one associations which benefit from eager loading
174
+ preloadable_association_names(resource)
175
+ rescue StandardError
176
+ []
177
+ end
178
+
179
+ def associations_from_attributes(attributes, resource)
180
+ return [] unless resource.respond_to?(:reflect_on_all_associations)
181
+
182
+ preloadable_associations = preloadable_association_names(resource)
183
+
184
+ # Only preload associations explicitly referenced in the attribute list.
185
+ attributes.select do |attr|
186
+ preloadable_associations.include?(attr)
187
+ end.uniq
188
+ rescue StandardError
189
+ []
190
+ end
191
+
192
+ def preloadable_association_names(resource)
193
+ resource.reflect_on_all_associations
194
+ .select { |reflection| %i[belongs_to has_one].include?(reflection.macro) }
195
+ .reject(&:polymorphic?)
196
+ .map(&:name)
197
+ end
198
+ end
199
+ end
200
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SuperAdmin
4
+ # Raised when the SuperAdmin engine is misconfigured.
5
+ class ConfigurationError < StandardError; end
6
+ end