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,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,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,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
|