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,84 @@
1
+ module SuperAdmin
2
+ module ApplicationHelper
3
+ include SuperAdmin::Engine.routes.url_helpers
4
+
5
+ def super_admin_root_path
6
+ SuperAdmin::Engine.routes.url_helpers.root_path
7
+ end
8
+
9
+ def super_admin_exports_path
10
+ SuperAdmin::Engine.routes.url_helpers.exports_path
11
+ end
12
+
13
+ def super_admin_audit_logs_path
14
+ SuperAdmin::Engine.routes.url_helpers.audit_logs_path
15
+ end
16
+
17
+ def pagy_nav(pagy)
18
+ return "" unless pagy
19
+
20
+ base_params = request.query_parameters.symbolize_keys.except(:page)
21
+ prev_link = pagy_page_link("Previous", pagy.page - 1, base_params) if pagy.page > 1
22
+ next_link = pagy_page_link("Next", pagy.page + 1, base_params) if pagy.page < pagy.pages
23
+
24
+ content_tag(:nav, class: "pagy-simple-nav", aria: { label: "Pagination" }) do
25
+ safe_join([
26
+ prev_link || pagy_disabled_page_link("Previous"),
27
+ content_tag(:span, "Page #{pagy.page} of #{pagy.pages}", class: "pagy-simple-nav__info"),
28
+ next_link || pagy_disabled_page_link("Next")
29
+ ], "\n".html_safe)
30
+ end
31
+ end
32
+
33
+ def audit_action_options
34
+ return [] unless audit_log_ready?
35
+
36
+ SuperAdmin::AuditLog.distinct.pluck(:action).compact.sort.map do |action|
37
+ [ action.humanize, action ]
38
+ end
39
+ end
40
+
41
+ def audit_resource_options
42
+ return [] unless audit_log_ready?
43
+
44
+ SuperAdmin::AuditLog.distinct.pluck(:resource_type).compact.sort.map do |type|
45
+ [ type, type ]
46
+ end
47
+ end
48
+
49
+ def action_badge_class(action)
50
+ case action.to_s
51
+ when "create"
52
+ "bg-green-100 text-green-800"
53
+ when "update"
54
+ "bg-blue-100 text-blue-800"
55
+ when "destroy", "bulk_destroy"
56
+ "bg-red-100 text-red-800"
57
+ else
58
+ "bg-gray-100 text-gray-800"
59
+ end
60
+ end
61
+
62
+ private
63
+
64
+ def pagy_page_link(label, page, params)
65
+ link_to label, pagy_page_url(page, params), class: "pagy-simple-nav__link"
66
+ end
67
+
68
+ def pagy_disabled_page_link(label)
69
+ content_tag(:span, label, class: "pagy-simple-nav__link pagy-simple-nav__link--disabled", aria: { disabled: true })
70
+ end
71
+
72
+ def pagy_page_url(page, params)
73
+ query = params.merge(page: page).compact_blank
74
+ query_string = query.to_query
75
+ query_string.present? ? "#{request.path}?#{query_string}" : request.path
76
+ end
77
+
78
+ def audit_log_ready?
79
+ SuperAdmin::AuditLog.table_exists?
80
+ rescue ActiveRecord::NoDatabaseError, ActiveRecord::StatementInvalid
81
+ false
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SuperAdmin
4
+ module ExportsHelper
5
+ BADGE_CLASSES = {
6
+ "pending" => "bg-yellow-100 text-yellow-800",
7
+ "processing" => "bg-blue-100 text-blue-800",
8
+ "ready" => "bg-green-100 text-green-800",
9
+ "failed" => "bg-red-100 text-red-800"
10
+ }.freeze
11
+
12
+ def export_badge_classes(export)
13
+ BADGE_CLASSES.fetch(export.status, "bg-gray-100 text-gray-800")
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,204 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SuperAdmin
4
+ # Helper for SuperAdmin views
5
+ module ResourcesHelper
6
+ # Formats attribute value for display
7
+ # @param resource [ActiveRecord::Base] The record
8
+ # @param attribute [String, Symbol] The attribute name
9
+ # @return [String] Formatted value
10
+ def format_attribute_value(resource, attribute)
11
+ value = resource.send(attribute)
12
+
13
+ case value
14
+ when nil
15
+ content_tag(:span, "—", class: "text-gray-400 italic")
16
+ when true
17
+ content_tag(:span, "✓", class: "text-green-600 font-bold")
18
+ when false
19
+ content_tag(:span, "✗", class: "text-red-600 font-bold")
20
+ when Date
21
+ l(value, format: :long)
22
+ when Time, DateTime, ActiveSupport::TimeWithZone
23
+ l(value, format: :long)
24
+ when Integer
25
+ number_with_delimiter(value)
26
+ when Float, BigDecimal
27
+ number_with_precision(value, precision: 2, delimiter: " ")
28
+ when String
29
+ value.present? ? value : content_tag(:span, t("super_admin.helpers.resources.empty_string"), class: "text-gray-400 italic")
30
+ else
31
+ value.to_s
32
+ end
33
+ end
34
+
35
+ # Displays the attribute value in a basic textual form used by tests and plain lists.
36
+ def display_attribute(resource, attribute)
37
+ value = resource.public_send(attribute)
38
+
39
+ return "" if value.nil?
40
+
41
+ # Handle ActiveRecord associations gracefully
42
+ if (reflection = resource.class.reflect_on_association(attribute))
43
+ return display_association_value(value, reflection)
44
+ end
45
+
46
+ # Enum attributes should be humanized
47
+ if resource.class.respond_to?(:defined_enums) && resource.class.defined_enums.key?(attribute.to_s)
48
+ return humanize_enum_value(resource, attribute, value)
49
+ end
50
+
51
+ case value
52
+ when TrueClass, FalseClass
53
+ value ? "Yes" : "No"
54
+ when Time, Date, DateTime, ActiveSupport::TimeWithZone
55
+ I18n.l(value)
56
+ else
57
+ value.to_s
58
+ end
59
+ end
60
+
61
+ # Humanizes a raw attribute name for display purposes.
62
+ def humanize_attribute(attribute)
63
+ attribute.to_s.tr("_", " ").capitalize
64
+ end
65
+
66
+ private
67
+
68
+ def display_association_value(value, reflection)
69
+ if reflection.collection?
70
+ value.map { |record| association_display_name(record) }.reject(&:blank?).join(", ")
71
+ else
72
+ association_display_name(value)
73
+ end
74
+ end
75
+
76
+ def association_display_name(record)
77
+ %i[name title email to_s].each do |method|
78
+ next unless record.respond_to?(method)
79
+
80
+ result = record.public_send(method)
81
+ return result.to_s if result.present?
82
+ end
83
+
84
+ ""
85
+ end
86
+
87
+ def humanize_enum_value(resource, attribute, value)
88
+ return "" if value.blank?
89
+
90
+ i18n_key = "#{attribute}.#{value}"
91
+ translation = resource.class.human_attribute_name(i18n_key, default: "")
92
+ return translation if translation.present?
93
+
94
+ value.to_s.tr("_", " ").split.map(&:capitalize).join(" ")
95
+ end
96
+
97
+ # Returns CSS class for status badge
98
+ # @param value [Object] The value to badge
99
+ # @return [String] CSS classes
100
+ def badge_class_for(value)
101
+ case value
102
+ when true
103
+ "bg-green-100 text-green-800"
104
+ when false
105
+ "bg-red-100 text-red-800"
106
+ when nil
107
+ "bg-gray-100 text-gray-800"
108
+ else
109
+ "bg-blue-100 text-blue-800"
110
+ end
111
+ end
112
+
113
+ # Returns icon for a column type
114
+ # @param column_type [Symbol] The column type
115
+ # @return [String] SVG icon
116
+ def icon_for_column_type(column_type)
117
+ icons = {
118
+ string: "M3 5h12M9 3v2m1.048 9.5A18.022 18.022 0 016.412 9m6.088 9h7M11 21l5-10 5 10M12.751 5C11.783 10.77 8.07 15.61 3 18.129",
119
+ text: "M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z",
120
+ integer: "M7 20l4-16m2 16l4-16M6 9h14M4 15h14",
121
+ decimal: "M9 7h6m0 10v-3m-3 3h.01M9 17h.01M9 14h.01M12 14h.01M15 11h.01M12 11h.01M9 11h.01M7 21h10a2 2 0 002-2V5a2 2 0 00-2-2H7a2 2 0 00-2 2v14a2 2 0 002 2z",
122
+ boolean: "M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z",
123
+ date: "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z",
124
+ datetime: "M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
125
+ }
126
+
127
+ path = icons[column_type] || icons[:string]
128
+
129
+ content_tag(:svg, class: "h-4 w-4", fill: "none", stroke: "currentColor", viewBox: "0 0 24 24") do
130
+ content_tag(:path, "", "stroke-linecap": "round", "stroke-linejoin": "round", "stroke-width": "2", d: path)
131
+ end
132
+ end
133
+
134
+ # Returns humanized label for a model
135
+ # @param model_class [Class] The model class
136
+ # @param count [Integer] Number for pluralization
137
+ # @return [String] Humanized label
138
+ def humanize_model_name(model_class, count: 2)
139
+ model_class.model_name.human(count: count)
140
+ end
141
+
142
+ # Generates sort options for a column
143
+ # @param attribute [String] The attribute name
144
+ # @param current_sort [String] Current sort attribute
145
+ # @param current_direction [String] Current sort direction
146
+ # @return [Hash] Parameters for sort link
147
+ def sort_params_for(attribute, current_sort: params[:sort], current_direction: params[:direction])
148
+ if current_sort == attribute
149
+ # Reverse direction if already sorting on this column
150
+ direction = current_direction == "asc" ? "desc" : "asc"
151
+ else
152
+ # Default direction
153
+ direction = "asc"
154
+ end
155
+
156
+ { sort: attribute, direction: direction }
157
+ end
158
+
159
+ # Returns sort indicator for a column
160
+ # @param attribute [String] The attribute name
161
+ # @param current_sort [String] Current sort attribute
162
+ # @param current_direction [String] Current sort direction
163
+ # @return [String, nil] Sort icon or nil
164
+ def sort_indicator_for(attribute, current_sort: params[:sort], current_direction: params[:direction])
165
+ return unless current_sort == attribute
166
+
167
+ if current_direction == "asc"
168
+ "↑"
169
+ else
170
+ "↓"
171
+ end
172
+ end
173
+
174
+ # Returns the current filter value to prefill forms
175
+ # @param applied_filters [Hash]
176
+ # @param key [String, Symbol]
177
+ # @return [String]
178
+ def filter_value(applied_filters, key)
179
+ return "" unless applied_filters
180
+
181
+ applied_filters[key.to_s] || applied_filters[key.to_sym] || ""
182
+ end
183
+
184
+ # Formats datetime value for datetime-local input field
185
+ def filter_datetime_value(applied_filters, key)
186
+ raw_value = filter_value(applied_filters, key)
187
+ return "" if raw_value.blank?
188
+
189
+ Time.zone.parse(raw_value.to_s).strftime("%Y-%m-%dT%H:%M")
190
+ rescue ArgumentError
191
+ raw_value
192
+ end
193
+
194
+ # Formats date value for date input field
195
+ def filter_date_value(applied_filters, key)
196
+ raw_value = filter_value(applied_filters, key)
197
+ return "" if raw_value.blank?
198
+
199
+ Date.parse(raw_value.to_s).strftime("%Y-%m-%d")
200
+ rescue ArgumentError
201
+ raw_value
202
+ end
203
+ end
204
+ end
@@ -0,0 +1,7 @@
1
+ module SuperAdmin
2
+ module RouteHelper
3
+ def super_admin_engine
4
+ SuperAdmin::Engine.routes.url_helpers
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,263 @@
1
+ // SuperAdmin JavaScript entry point
2
+ // This file auto-registers all Stimulus controllers with the host app's Stimulus instance
3
+
4
+ import { Controller } from "@hotwired/stimulus"
5
+
6
+ // Define controllers inline to avoid module loading issues
7
+ class AssociationSelectController extends Controller {
8
+ static targets = ["select", "search", "results"]
9
+ static values = {
10
+ model: String,
11
+ url: String,
12
+ selectedId: String
13
+ }
14
+
15
+ connect() {
16
+ if (this.hasSelectTarget && this.selectTarget.dataset.searchable === "true") {
17
+ this.enhanceSelect()
18
+ }
19
+ }
20
+
21
+ enhanceSelect() {
22
+ const select = this.selectTarget
23
+ if (select.dataset.searchEnhanced === "true") {
24
+ return
25
+ }
26
+
27
+ const totalCount = parseInt(select.dataset.totalCount || "0")
28
+ const selectLimit = parseInt(select.querySelectorAll('option').length - 1) || 0
29
+
30
+ if (totalCount <= selectLimit) {
31
+ return
32
+ }
33
+
34
+ select.dataset.searchEnhanced = "true"
35
+ this.createSearchInterface()
36
+ }
37
+
38
+ createSearchInterface() {
39
+ const select = this.selectTarget
40
+ const wrapper = document.createElement("div")
41
+ wrapper.className = "relative"
42
+
43
+ const searchInput = document.createElement("input")
44
+ searchInput.type = "text"
45
+ searchInput.placeholder = "Rechercher..."
46
+ searchInput.className = "block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm px-3 py-2 mb-2"
47
+ searchInput.dataset.superAdminAssociationSelectTarget = "search"
48
+
49
+ let searchTimeout
50
+ searchInput.addEventListener("input", (e) => {
51
+ clearTimeout(searchTimeout)
52
+ searchTimeout = setTimeout(() => {
53
+ this.performSearch(e.target.value)
54
+ }, 300)
55
+ })
56
+
57
+ select.parentNode.insertBefore(wrapper, select)
58
+ wrapper.appendChild(searchInput)
59
+ wrapper.appendChild(select)
60
+ }
61
+
62
+ async performSearch(query) {
63
+ const select = this.selectTarget
64
+ const model = select.dataset.association
65
+ const selectedId = select.value
66
+
67
+ if (!model) {
68
+ console.error("Association model not defined")
69
+ return
70
+ }
71
+
72
+ try {
73
+ const url = new URL("/super_admin/associations/search", window.location.origin)
74
+ url.searchParams.set("model", model)
75
+ url.searchParams.set("q", query)
76
+ url.searchParams.set("page", "1")
77
+ if (selectedId) {
78
+ url.searchParams.set("selected_id", selectedId)
79
+ }
80
+
81
+ const response = await fetch(url)
82
+ if (!response.ok) {
83
+ throw new Error(`HTTP ${response.status}`)
84
+ }
85
+
86
+ const data = await response.json()
87
+ this.updateSelectOptions(data.results, selectedId)
88
+ } catch (error) {
89
+ console.error("Association search failed:", error)
90
+ }
91
+ }
92
+
93
+ updateSelectOptions(results, selectedId) {
94
+ const select = this.selectTarget
95
+ const hasBlank = select.querySelector('option[value=""]')
96
+
97
+ select.innerHTML = ""
98
+ if (hasBlank) {
99
+ const blankOption = document.createElement("option")
100
+ blankOption.value = ""
101
+ blankOption.textContent = ""
102
+ select.appendChild(blankOption)
103
+ }
104
+
105
+ results.forEach(result => {
106
+ const option = document.createElement("option")
107
+ option.value = result.id
108
+ option.textContent = result.text
109
+ if (result.id.toString() === selectedId) {
110
+ option.selected = true
111
+ }
112
+ select.appendChild(option)
113
+ })
114
+ }
115
+ }
116
+
117
+ class BulkSelectionController extends Controller {
118
+ static targets = ["checkbox", "toggle", "counter"]
119
+
120
+ connect() {
121
+ this.updateCounter()
122
+ }
123
+
124
+ toggleAll(event) {
125
+ const checked = event.target.checked
126
+ this.checkboxTargets.forEach((checkbox) => {
127
+ checkbox.checked = checked
128
+ })
129
+ if (this.hasToggleTarget) {
130
+ this.toggleTarget.indeterminate = false
131
+ }
132
+ this.updateCounter()
133
+ }
134
+
135
+ itemChanged() {
136
+ const allChecked = this.checkboxTargets.length > 0 && this.checkboxTargets.every((checkbox) => checkbox.checked)
137
+ if (this.hasToggleTarget) {
138
+ this.toggleTarget.indeterminate = !allChecked && this.checkboxTargets.some((checkbox) => checkbox.checked)
139
+ this.toggleTarget.checked = allChecked
140
+ }
141
+ this.updateCounter()
142
+ }
143
+
144
+ updateCounter() {
145
+ if (!this.hasCounterTarget) return
146
+
147
+ const selected = this.checkboxTargets.filter((checkbox) => checkbox.checked).length
148
+ this.counterTarget.textContent = selected
149
+ }
150
+ }
151
+
152
+ class NestedFormController extends Controller {
153
+ static targets = ["entries", "template"]
154
+
155
+ add(event) {
156
+ event.preventDefault()
157
+ if (!this.hasTemplateTarget) return
158
+
159
+ const timestamp = Date.now().toString()
160
+ const content = this.templateTarget.innerHTML.replace(/__NEW_RECORD__/g, timestamp)
161
+ this.entriesTarget.insertAdjacentHTML("beforeend", content)
162
+ }
163
+
164
+ remove(event) {
165
+ event.preventDefault()
166
+ const button = event.target.closest("[data-super-admin--nested-form-remove]") || event.target
167
+ const wrapper = button.closest("[data-super-admin--nested-form-entry]")
168
+ if (!wrapper) return
169
+
170
+ const destroyInput = wrapper.querySelector("input[name$='[_destroy]']")
171
+ const idInput = wrapper.querySelector("input[name$='[id]']")
172
+
173
+ if (destroyInput) {
174
+ destroyInput.value = "1"
175
+ }
176
+
177
+ if (idInput && idInput.value !== "") {
178
+ wrapper.classList.add("hidden")
179
+ } else {
180
+ wrapper.remove()
181
+ }
182
+ }
183
+ }
184
+
185
+ class FlashController extends Controller {
186
+ static values = {
187
+ autoDismiss: { type: Number, default: 5000 }
188
+ }
189
+
190
+ connect() {
191
+ if (this.autoDismissValue > 0) {
192
+ this.timeout = setTimeout(() => {
193
+ this.close()
194
+ }, this.autoDismissValue)
195
+ }
196
+ }
197
+
198
+ disconnect() {
199
+ if (this.timeout) {
200
+ clearTimeout(this.timeout)
201
+ }
202
+ }
203
+
204
+ close() {
205
+ // Use inline style for fade out animation
206
+ this.element.style.transition = 'opacity 0.3s ease-out, transform 0.3s ease-out'
207
+ this.element.style.opacity = '0'
208
+ this.element.style.transform = 'translateY(-10px)'
209
+
210
+ setTimeout(() => {
211
+ this.element.remove()
212
+ }, 300)
213
+ }
214
+
215
+ remove() {
216
+ this.element.remove()
217
+ }
218
+ }
219
+
220
+ class MobileMenuController extends Controller {
221
+ static targets = ["sidebar", "overlay"]
222
+
223
+ toggle() {
224
+ if (this.hasSidebarTarget && this.hasOverlayTarget) {
225
+ this.sidebarTarget.classList.toggle('-translate-x-full')
226
+ this.overlayTarget.classList.toggle('hidden')
227
+ }
228
+ }
229
+
230
+ close(event) {
231
+ // Close on overlay click or escape key
232
+ if (event.type === 'click' || (event.type === 'keydown' && event.key === 'Escape')) {
233
+ if (this.hasSidebarTarget && this.hasOverlayTarget) {
234
+ if (!this.overlayTarget.classList.contains('hidden')) {
235
+ this.toggle()
236
+ }
237
+ }
238
+ }
239
+ }
240
+ }
241
+
242
+ // Auto-register with the host app's Stimulus application
243
+ function registerControllers() {
244
+ if (window.Stimulus) {
245
+ window.Stimulus.register("super-admin--association-select", AssociationSelectController)
246
+ window.Stimulus.register("super-admin--bulk-selection", BulkSelectionController)
247
+ window.Stimulus.register("super-admin--nested-form", NestedFormController)
248
+ window.Stimulus.register("super-admin--flash", FlashController)
249
+ window.Stimulus.register("super-admin--mobile-menu", MobileMenuController)
250
+ } else {
251
+ console.warn("SuperAdmin: Stimulus not found. Controllers will not be registered.")
252
+ }
253
+ }
254
+
255
+ // Register immediately if Stimulus is already loaded
256
+ if (window.Stimulus) {
257
+ registerControllers()
258
+ } else {
259
+ // Otherwise wait for Stimulus to load
260
+ document.addEventListener("DOMContentLoaded", () => {
261
+ setTimeout(registerControllers, 100)
262
+ })
263
+ }
@@ -0,0 +1,4 @@
1
+ module SuperAdmin
2
+ class ApplicationJob < ActiveJob::Base
3
+ end
4
+ end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Generates a CSV export in the background for SuperAdmin.
4
+ module SuperAdmin
5
+ class GenerateSuperAdminCsvExportJob < ApplicationJob
6
+ queue_as :default
7
+
8
+ def perform(export_reference = nil, **options)
9
+ export = nil
10
+ args = normalize_arguments(export_reference, options)
11
+
12
+ export = SuperAdmin::CsvExport.find(args.fetch(:export_id))
13
+ export.mark_processing!
14
+
15
+ model_class_name = args[:model_class_name] || export.model_class_name || export.resource_name.classify
16
+ model_class = SuperAdmin::ModelInspector.find_model(model_class_name) || SuperAdmin::ModelInspector.find_model(export.resource_name)
17
+ raise ActiveRecord::RecordNotFound, export.resource_name unless model_class
18
+
19
+ scope = SuperAdmin::ResourceQuery.filtered_scope(
20
+ model_class,
21
+ search: export.search,
22
+ sort: export.sort,
23
+ direction: export.direction,
24
+ filters: export.filters
25
+ )
26
+
27
+ if args[:scope_params].present? && scope.respond_to?(:merge)
28
+ scope = scope.merge(model_class.where(args[:scope_params]))
29
+ end
30
+
31
+ records_count = scope.count
32
+
33
+ exporter = SuperAdmin::ResourceExporter.new(
34
+ model_class,
35
+ scope,
36
+ attributes: args[:attributes].presence || export.selected_attributes.presence || SuperAdmin::DashboardResolver.collection_attributes_for(model_class)
37
+ )
38
+
39
+ attach_csv(export, exporter)
40
+ export.mark_ready!(records_count: records_count)
41
+ rescue StandardError => error
42
+ export_identifier = (args && args[:export_id]) || export&.id
43
+ Rails.logger.error("[SuperAdmin::GenerateSuperAdminCsvExportJob] Export ##{export_identifier} failed: #{error.class} - #{error.message}")
44
+ export&.mark_failed!(error.message)
45
+ raise
46
+ ensure
47
+ cleanup_tempfile
48
+ end
49
+
50
+ private
51
+
52
+ def normalize_arguments(export_reference, options)
53
+ params =
54
+ case export_reference
55
+ when Hash
56
+ export_reference
57
+ when nil
58
+ options
59
+ else
60
+ options.merge(export_id: export_reference)
61
+ end
62
+
63
+ params.to_h.transform_keys(&:to_sym)
64
+ end
65
+
66
+ def attach_csv(export, exporter)
67
+ @tempfile = Tempfile.new([ export.resource_name, ".csv" ], binmode: true)
68
+ exporter.write_to(@tempfile)
69
+ @tempfile.rewind
70
+
71
+ unless active_storage_available?
72
+ Rails.logger.warn("[SuperAdmin::GenerateSuperAdminCsvExportJob] ActiveStorage tables missing; skipping CSV attach for export ##{export.id}")
73
+ return
74
+ end
75
+
76
+ export.file.attach(
77
+ io: @tempfile,
78
+ filename: "#{export.resource_name}-#{Time.current.strftime('%Y%m%d-%H%M%S')}.csv",
79
+ content_type: "text/csv"
80
+ )
81
+ end
82
+
83
+ def active_storage_available?
84
+ return false unless defined?(ActiveStorage::Attachment)
85
+
86
+ ActiveStorage::Attachment.table_exists? && ActiveStorage::Blob.table_exists?
87
+ rescue ActiveRecord::StatementInvalid, ActiveRecord::NoDatabaseError
88
+ false
89
+ end
90
+
91
+ def cleanup_tempfile
92
+ return unless defined?(@tempfile) && @tempfile
93
+
94
+ @tempfile.close
95
+ @tempfile.unlink
96
+ rescue StandardError => error
97
+ Rails.logger.warn("[SuperAdmin::GenerateSuperAdminCsvExportJob] Tempfile cleanup warning: #{error.message}")
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,6 @@
1
+ module SuperAdmin
2
+ class ApplicationMailer < ActionMailer::Base
3
+ default from: "from@example.com"
4
+ layout "mailer"
5
+ end
6
+ end
@@ -0,0 +1,5 @@
1
+ module SuperAdmin
2
+ class ApplicationRecord < ActiveRecord::Base
3
+ self.abstract_class = true
4
+ end
5
+ end