elaine_crud 0.1.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/.rspec +3 -0
- data/LICENSE +21 -0
- data/README.md +225 -0
- data/Rakefile +9 -0
- data/TODO.md +496 -0
- data/app/controllers/elaine_crud/base_controller.rb +228 -0
- data/app/helpers/elaine_crud/base_helper.rb +787 -0
- data/app/helpers/elaine_crud/search_helper.rb +132 -0
- data/app/javascript/controllers/dropdown_controller.js +18 -0
- data/app/views/elaine_crud/base/_edit_row.html.erb +60 -0
- data/app/views/elaine_crud/base/_export_button.html.erb +88 -0
- data/app/views/elaine_crud/base/_foreign_key_select_refresh.html.erb +52 -0
- data/app/views/elaine_crud/base/_form.html.erb +45 -0
- data/app/views/elaine_crud/base/_form_fields.html.erb +45 -0
- data/app/views/elaine_crud/base/_index_table.html.erb +58 -0
- data/app/views/elaine_crud/base/_modal.html.erb +71 -0
- data/app/views/elaine_crud/base/_pagination.html.erb +110 -0
- data/app/views/elaine_crud/base/_per_page_selector.html.erb +30 -0
- data/app/views/elaine_crud/base/_search_bar.html.erb +75 -0
- data/app/views/elaine_crud/base/_show_details.html.erb +29 -0
- data/app/views/elaine_crud/base/_view_row.html.erb +96 -0
- data/app/views/elaine_crud/base/edit.html.erb +51 -0
- data/app/views/elaine_crud/base/index.html.erb +74 -0
- data/app/views/elaine_crud/base/new.html.erb +12 -0
- data/app/views/elaine_crud/base/new_modal.html.erb +37 -0
- data/app/views/elaine_crud/base/not_found.html.erb +49 -0
- data/app/views/elaine_crud/base/show.html.erb +32 -0
- data/docs/ARCHITECTURE.md +410 -0
- data/docs/CSS_GRID_LAYOUT.md +126 -0
- data/docs/DEMO.md +693 -0
- data/docs/DSL_EXAMPLES.md +313 -0
- data/docs/FOREIGN_KEY_EXAMPLE.rb +100 -0
- data/docs/FOREIGN_KEY_SUPPORT.md +197 -0
- data/docs/HAS_MANY_IMPLEMENTATION.md +154 -0
- data/docs/LAYOUT_EXAMPLES.md +301 -0
- data/docs/TROUBLESHOOTING.md +170 -0
- data/elaine_crud.gemspec +46 -0
- data/lib/elaine_crud/dsl_methods.rb +348 -0
- data/lib/elaine_crud/engine.rb +37 -0
- data/lib/elaine_crud/export_handling.rb +164 -0
- data/lib/elaine_crud/field_configuration.rb +422 -0
- data/lib/elaine_crud/field_configuration_methods.rb +152 -0
- data/lib/elaine_crud/layout_calculation.rb +55 -0
- data/lib/elaine_crud/parameter_handling.rb +48 -0
- data/lib/elaine_crud/record_fetching.rb +150 -0
- data/lib/elaine_crud/relationship_handling.rb +220 -0
- data/lib/elaine_crud/routing.rb +33 -0
- data/lib/elaine_crud/search_and_filtering.rb +285 -0
- data/lib/elaine_crud/sorting_concern.rb +65 -0
- data/lib/elaine_crud/version.rb +5 -0
- data/lib/elaine_crud.rb +25 -0
- data/lib/tasks/demo.rake +111 -0
- data/lib/tasks/spec.rake +26 -0
- metadata +264 -0
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ElaineCrud
|
|
4
|
+
# DSL methods for controller configuration
|
|
5
|
+
# Provides class-level methods for configuring CRUD behavior
|
|
6
|
+
module DSLMethods
|
|
7
|
+
extend ActiveSupport::Concern
|
|
8
|
+
|
|
9
|
+
included do
|
|
10
|
+
class_attribute :crud_model, :permitted_attributes, :column_configurations,
|
|
11
|
+
:field_configurations, :default_sort_column, :default_sort_direction,
|
|
12
|
+
:disable_turbo_frames, :show_view_action_button, :max_export_records
|
|
13
|
+
|
|
14
|
+
# Default: View button is disabled
|
|
15
|
+
self.show_view_action_button = false
|
|
16
|
+
|
|
17
|
+
# Default: Max 10,000 records for export
|
|
18
|
+
self.max_export_records = 10_000
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
class_methods do
|
|
22
|
+
# Specify the ActiveRecord model this controller manages
|
|
23
|
+
# @param model_class [Class] The ActiveRecord model class
|
|
24
|
+
def model(model_class)
|
|
25
|
+
self.crud_model = model_class
|
|
26
|
+
# Auto-configure foreign key fields after setting the model
|
|
27
|
+
auto_configure_foreign_keys if model_class
|
|
28
|
+
# Auto-configure has_many relationships
|
|
29
|
+
auto_configure_has_many_relationships if model_class
|
|
30
|
+
# Auto-configure has_one relationships
|
|
31
|
+
auto_configure_has_one_relationships if model_class
|
|
32
|
+
# Auto-configure has_and_belongs_to_many relationships
|
|
33
|
+
auto_configure_habtm_relationships if model_class
|
|
34
|
+
# Re-run permit_params to include foreign keys if it was called before model was set
|
|
35
|
+
refresh_permitted_attributes
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Specify permitted parameters for strong params
|
|
39
|
+
# @param attrs [Array<Symbol>] List of permitted attributes
|
|
40
|
+
def permit_params(*attrs)
|
|
41
|
+
# Store manual attributes for later refresh if needed
|
|
42
|
+
@manual_permitted_attributes = attrs
|
|
43
|
+
|
|
44
|
+
# Auto-include foreign keys from belongs_to relationships
|
|
45
|
+
foreign_keys = get_foreign_keys_from_model
|
|
46
|
+
final_attrs = (attrs + foreign_keys).uniq
|
|
47
|
+
self.permitted_attributes = final_attrs
|
|
48
|
+
|
|
49
|
+
# Debug logging for development
|
|
50
|
+
if Rails.env.development?
|
|
51
|
+
Rails.logger.info "ElaineCrud: Setting permitted attributes to: #{final_attrs.inspect}"
|
|
52
|
+
Rails.logger.info "ElaineCrud: Manual attributes: #{attrs.inspect}"
|
|
53
|
+
Rails.logger.info "ElaineCrud: Auto-detected foreign keys: #{foreign_keys.inspect}"
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Get foreign keys from belongs_to relationships and HABTM singular_ids
|
|
58
|
+
# @return [Array<Symbol>] List of foreign key attributes
|
|
59
|
+
def get_foreign_keys_from_model
|
|
60
|
+
return [] unless crud_model
|
|
61
|
+
|
|
62
|
+
foreign_keys = []
|
|
63
|
+
crud_model.reflections.each do |name, reflection|
|
|
64
|
+
case reflection
|
|
65
|
+
when ActiveRecord::Reflection::BelongsToReflection
|
|
66
|
+
foreign_keys << reflection.foreign_key.to_sym
|
|
67
|
+
when ActiveRecord::Reflection::HasAndBelongsToManyReflection
|
|
68
|
+
# Add the singular_ids parameter for HABTM (e.g., tag_ids for tags)
|
|
69
|
+
foreign_keys << { "#{name.to_s.singularize}_ids".to_sym => [] }
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
foreign_keys
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Store the manually specified attributes for later use
|
|
77
|
+
attr_accessor :manual_permitted_attributes
|
|
78
|
+
|
|
79
|
+
# Refresh permitted attributes (called when model is set after permit_params)
|
|
80
|
+
def refresh_permitted_attributes
|
|
81
|
+
return unless @manual_permitted_attributes
|
|
82
|
+
|
|
83
|
+
foreign_keys = get_foreign_keys_from_model
|
|
84
|
+
final_attrs = (@manual_permitted_attributes + foreign_keys).uniq
|
|
85
|
+
self.permitted_attributes = final_attrs
|
|
86
|
+
|
|
87
|
+
if Rails.env.development?
|
|
88
|
+
Rails.logger.info "ElaineCrud: Refreshed permitted attributes to: #{final_attrs.inspect}"
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Configure columns (for future use)
|
|
93
|
+
# @param config [Hash] Column configuration
|
|
94
|
+
def columns(config = {})
|
|
95
|
+
self.column_configurations = config
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Configure individual field properties and behavior
|
|
99
|
+
# @param field_name [Symbol] The field name
|
|
100
|
+
# @param options [Hash] Configuration options (title, description, readonly, etc.)
|
|
101
|
+
# @yield [FieldConfiguration] Block for DSL-style configuration
|
|
102
|
+
def field(field_name, **options, &block)
|
|
103
|
+
self.field_configurations ||= {}
|
|
104
|
+
|
|
105
|
+
config = ElaineCrud::FieldConfiguration.new(field_name, **options)
|
|
106
|
+
config.instance_eval(&block) if block_given?
|
|
107
|
+
|
|
108
|
+
self.field_configurations[field_name.to_sym] = config
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Configure default sorting
|
|
112
|
+
# @param column [Symbol] The column to sort by (default: :id)
|
|
113
|
+
# @param direction [Symbol] The sort direction :asc or :desc (default: :asc)
|
|
114
|
+
def default_sort(column: :id, direction: :asc)
|
|
115
|
+
self.default_sort_column = column
|
|
116
|
+
self.default_sort_direction = direction
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Disable Turbo Frame functionality for this controller
|
|
120
|
+
# When disabled, edit links will navigate to full page instead of inline editing
|
|
121
|
+
def disable_turbo
|
|
122
|
+
self.disable_turbo_frames = true
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Enable the "View" button in the actions column for index/list views
|
|
126
|
+
# @param enabled [Boolean] Whether to show the View button (default: true)
|
|
127
|
+
def show_view_button(enabled = true)
|
|
128
|
+
self.show_view_action_button = enabled
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Set the maximum number of records that can be exported
|
|
132
|
+
# @param limit [Integer] Maximum number of records (default: 10,000)
|
|
133
|
+
def max_export(limit)
|
|
134
|
+
self.max_export_records = limit
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Automatically configure foreign key fields based on belongs_to relationships
|
|
138
|
+
# This method is called automatically when the model is set
|
|
139
|
+
def auto_configure_foreign_keys
|
|
140
|
+
return unless crud_model
|
|
141
|
+
|
|
142
|
+
# Initialize field configurations if not already done
|
|
143
|
+
self.field_configurations ||= {}
|
|
144
|
+
|
|
145
|
+
# Find all belongs_to relationships
|
|
146
|
+
crud_model.reflections.each do |name, reflection|
|
|
147
|
+
next unless reflection.is_a?(ActiveRecord::Reflection::BelongsToReflection)
|
|
148
|
+
|
|
149
|
+
foreign_key = reflection.foreign_key.to_sym
|
|
150
|
+
|
|
151
|
+
# Skip if already manually configured
|
|
152
|
+
next if field_configurations[foreign_key]
|
|
153
|
+
|
|
154
|
+
# Auto-configure this foreign key field
|
|
155
|
+
auto_configure_belongs_to_field(reflection)
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Auto-configure has_many relationship display
|
|
160
|
+
def auto_configure_has_many_relationships
|
|
161
|
+
return unless crud_model
|
|
162
|
+
|
|
163
|
+
self.field_configurations ||= {}
|
|
164
|
+
|
|
165
|
+
# Find all has_many relationships
|
|
166
|
+
crud_model.reflections.each do |name, reflection|
|
|
167
|
+
next unless reflection.is_a?(ActiveRecord::Reflection::HasManyReflection)
|
|
168
|
+
|
|
169
|
+
# Skip if already manually configured
|
|
170
|
+
field_name = name.to_sym
|
|
171
|
+
next if field_configurations[field_name]
|
|
172
|
+
|
|
173
|
+
# Auto-configure this has_many field
|
|
174
|
+
auto_configure_has_many_field(reflection)
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# Auto-configure has_one relationship display
|
|
179
|
+
def auto_configure_has_one_relationships
|
|
180
|
+
return unless crud_model
|
|
181
|
+
|
|
182
|
+
self.field_configurations ||= {}
|
|
183
|
+
|
|
184
|
+
# Find all has_one relationships
|
|
185
|
+
crud_model.reflections.each do |name, reflection|
|
|
186
|
+
next unless reflection.is_a?(ActiveRecord::Reflection::HasOneReflection)
|
|
187
|
+
|
|
188
|
+
# Skip if already manually configured
|
|
189
|
+
field_name = name.to_sym
|
|
190
|
+
next if field_configurations[field_name]
|
|
191
|
+
|
|
192
|
+
# Auto-configure this has_one field
|
|
193
|
+
auto_configure_has_one_field(reflection)
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# Configure has_many relationship display and behavior
|
|
198
|
+
def has_many_relation(relation_name, **options, &block)
|
|
199
|
+
self.field_configurations ||= {}
|
|
200
|
+
|
|
201
|
+
config = ElaineCrud::FieldConfiguration.new(relation_name, **options)
|
|
202
|
+
config.instance_eval(&block) if block_given?
|
|
203
|
+
|
|
204
|
+
# Set has_many specific configuration - extract only the has_many hash
|
|
205
|
+
if options[:has_many]
|
|
206
|
+
config.has_many(**options[:has_many])
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
self.field_configurations[relation_name.to_sym] = config
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# Determine the best display field for a model (class method)
|
|
213
|
+
# @param model_class [Class] The ActiveRecord model class
|
|
214
|
+
# @return [Symbol] The field to use for display
|
|
215
|
+
def determine_display_field_for_model(model_class)
|
|
216
|
+
# Common field names for display, in order of preference
|
|
217
|
+
display_candidates = [:name, :title, :display_name, :full_name, :label, :description]
|
|
218
|
+
|
|
219
|
+
# Check which columns exist
|
|
220
|
+
column_names = model_class.column_names.map(&:to_sym)
|
|
221
|
+
|
|
222
|
+
# Return the first matching candidate
|
|
223
|
+
display_field = display_candidates.find { |candidate| column_names.include?(candidate) }
|
|
224
|
+
|
|
225
|
+
# If none found, use the first string/text column that's not id, created_at, updated_at
|
|
226
|
+
unless display_field
|
|
227
|
+
string_columns = model_class.columns.select do |col|
|
|
228
|
+
[:string, :text].include?(col.type) &&
|
|
229
|
+
!%w[id created_at updated_at].include?(col.name)
|
|
230
|
+
end
|
|
231
|
+
display_field = string_columns.first&.name&.to_sym
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# Final fallback to :id
|
|
235
|
+
display_field || :id
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
private
|
|
239
|
+
|
|
240
|
+
# Auto-configure a single belongs_to foreign key field
|
|
241
|
+
# @param reflection [ActiveRecord::Reflection::BelongsToReflection] The belongs_to reflection
|
|
242
|
+
def auto_configure_belongs_to_field(reflection)
|
|
243
|
+
foreign_key = reflection.foreign_key.to_sym
|
|
244
|
+
related_model = reflection.klass
|
|
245
|
+
|
|
246
|
+
# Try to determine the display field for the related model
|
|
247
|
+
display_field = determine_display_field_for_model(related_model)
|
|
248
|
+
|
|
249
|
+
# Create field configuration
|
|
250
|
+
config = ElaineCrud::FieldConfiguration.new(
|
|
251
|
+
foreign_key,
|
|
252
|
+
title: reflection.name.to_s.humanize,
|
|
253
|
+
foreign_key: {
|
|
254
|
+
model: related_model,
|
|
255
|
+
display: display_field,
|
|
256
|
+
null_option: "Select #{reflection.name.to_s.humanize}"
|
|
257
|
+
}
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
self.field_configurations[foreign_key] = config
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
# Auto-configure a single has_many relationship field
|
|
264
|
+
def auto_configure_has_many_field(reflection)
|
|
265
|
+
field_name = reflection.name.to_sym
|
|
266
|
+
related_model = reflection.klass
|
|
267
|
+
|
|
268
|
+
# Determine display field for related records
|
|
269
|
+
display_field = determine_display_field_for_model(related_model)
|
|
270
|
+
|
|
271
|
+
# Create field configuration for has_many
|
|
272
|
+
config = ElaineCrud::FieldConfiguration.new(
|
|
273
|
+
field_name,
|
|
274
|
+
title: reflection.name.to_s.humanize,
|
|
275
|
+
has_many: {
|
|
276
|
+
model: related_model,
|
|
277
|
+
display: display_field,
|
|
278
|
+
foreign_key: reflection.foreign_key,
|
|
279
|
+
show_count: true,
|
|
280
|
+
max_preview_items: 3
|
|
281
|
+
}
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
self.field_configurations[field_name] = config
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
# Auto-configure a single has_one relationship field
|
|
288
|
+
def auto_configure_has_one_field(reflection)
|
|
289
|
+
field_name = reflection.name.to_sym
|
|
290
|
+
related_model = reflection.klass
|
|
291
|
+
|
|
292
|
+
# Determine display field for related record
|
|
293
|
+
display_field = determine_display_field_for_model(related_model)
|
|
294
|
+
|
|
295
|
+
# Create field configuration for has_one
|
|
296
|
+
config = ElaineCrud::FieldConfiguration.new(
|
|
297
|
+
field_name,
|
|
298
|
+
title: reflection.name.to_s.humanize,
|
|
299
|
+
has_one: {
|
|
300
|
+
model: related_model,
|
|
301
|
+
display: display_field,
|
|
302
|
+
foreign_key: reflection.foreign_key
|
|
303
|
+
}
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
self.field_configurations[field_name] = config
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
# Auto-configure has_and_belongs_to_many relationship display
|
|
310
|
+
def auto_configure_habtm_relationships
|
|
311
|
+
return unless crud_model
|
|
312
|
+
|
|
313
|
+
self.field_configurations ||= {}
|
|
314
|
+
|
|
315
|
+
# Find all HABTM relationships
|
|
316
|
+
crud_model.reflections.each do |name, reflection|
|
|
317
|
+
next unless reflection.is_a?(ActiveRecord::Reflection::HasAndBelongsToManyReflection)
|
|
318
|
+
|
|
319
|
+
# Skip if already manually configured
|
|
320
|
+
field_name = name.to_sym
|
|
321
|
+
next if field_configurations[field_name]
|
|
322
|
+
|
|
323
|
+
# Auto-configure this HABTM field
|
|
324
|
+
auto_configure_habtm_field(reflection)
|
|
325
|
+
end
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
# Auto-configure a single HABTM relationship field - minimal implementation
|
|
329
|
+
# Applications should use display_as for custom rendering
|
|
330
|
+
def auto_configure_habtm_field(reflection)
|
|
331
|
+
field_name = reflection.name.to_sym
|
|
332
|
+
related_model = reflection.klass
|
|
333
|
+
|
|
334
|
+
# Create minimal field configuration for HABTM
|
|
335
|
+
config = ElaineCrud::FieldConfiguration.new(
|
|
336
|
+
field_name,
|
|
337
|
+
title: reflection.name.to_s.humanize,
|
|
338
|
+
habtm: {
|
|
339
|
+
model: related_model,
|
|
340
|
+
display_field: determine_display_field_for_model(related_model)
|
|
341
|
+
}
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
self.field_configurations[field_name] = config
|
|
345
|
+
end
|
|
346
|
+
end
|
|
347
|
+
end
|
|
348
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'rails/engine'
|
|
4
|
+
require 'turbo-rails'
|
|
5
|
+
|
|
6
|
+
module ElaineCrud
|
|
7
|
+
class Engine < ::Rails::Engine
|
|
8
|
+
# Non-mountable engine - do not call isolate_namespace
|
|
9
|
+
|
|
10
|
+
# Make sure our app directories and lib directories are available
|
|
11
|
+
config.autoload_paths << File.expand_path('../../app/controllers', __dir__)
|
|
12
|
+
config.autoload_paths << File.expand_path('../../app/helpers', __dir__)
|
|
13
|
+
config.autoload_paths << File.expand_path('..', __dir__)
|
|
14
|
+
|
|
15
|
+
# Ensure views are available in the view path
|
|
16
|
+
initializer 'elaine_crud.append_view_paths' do |app|
|
|
17
|
+
ActiveSupport.on_load :action_controller do
|
|
18
|
+
append_view_path File.expand_path('../../app/views', __dir__)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Include helpers in ActionController::Base so they're available everywhere
|
|
23
|
+
initializer 'elaine_crud.include_helpers' do
|
|
24
|
+
ActiveSupport.on_load :action_controller do
|
|
25
|
+
include ElaineCrud::BaseHelper
|
|
26
|
+
include ElaineCrud::SearchHelper
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Extend ActionDispatch::Routing::Mapper to add custom routing DSL
|
|
31
|
+
# This must run before routes are loaded
|
|
32
|
+
initializer 'elaine_crud.add_routing_helper', before: :add_routing_paths do
|
|
33
|
+
require 'elaine_crud/routing'
|
|
34
|
+
ActionDispatch::Routing::Mapper.include ElaineCrud::Routing
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'csv'
|
|
4
|
+
require 'caxlsx'
|
|
5
|
+
|
|
6
|
+
module ElaineCrud
|
|
7
|
+
# Handles data export functionality (CSV, Excel, JSON)
|
|
8
|
+
# Provides export action and format generation methods
|
|
9
|
+
module ExportHandling
|
|
10
|
+
extend ActiveSupport::Concern
|
|
11
|
+
|
|
12
|
+
# Export records in CSV, Excel (XLSX), or JSON format
|
|
13
|
+
# Respects current search/filter state and enforces max export limits
|
|
14
|
+
def export
|
|
15
|
+
# Fetch records without pagination for export
|
|
16
|
+
@records = fetch_records_for_export
|
|
17
|
+
|
|
18
|
+
# Check record count
|
|
19
|
+
if @records.count > self.class.max_export_records
|
|
20
|
+
redirect_to polymorphic_path(crud_model),
|
|
21
|
+
alert: "Cannot export more than #{self.class.max_export_records} records. Please apply filters to reduce the number of records.",
|
|
22
|
+
status: :see_other
|
|
23
|
+
return
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
@columns = determine_columns
|
|
27
|
+
@model_name = crud_model.name
|
|
28
|
+
|
|
29
|
+
respond_to do |format|
|
|
30
|
+
format.csv do
|
|
31
|
+
send_data generate_csv(@records, @columns),
|
|
32
|
+
filename: export_filename('csv'),
|
|
33
|
+
type: 'text/csv',
|
|
34
|
+
disposition: 'attachment'
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
format.xlsx do
|
|
38
|
+
send_data generate_xlsx(@records, @columns),
|
|
39
|
+
filename: export_filename('xlsx'),
|
|
40
|
+
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
41
|
+
disposition: 'attachment'
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
format.json do
|
|
45
|
+
render json: generate_json(@records, @columns),
|
|
46
|
+
status: :ok
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
# Generate CSV content from records
|
|
54
|
+
def generate_csv(records, columns)
|
|
55
|
+
CSV.generate(headers: true) do |csv|
|
|
56
|
+
# Header row
|
|
57
|
+
csv << columns.map { |col| field_title(col.to_sym) }
|
|
58
|
+
|
|
59
|
+
# Data rows
|
|
60
|
+
records.each do |record|
|
|
61
|
+
csv << columns.map { |col| export_field_value(record, col.to_sym) }
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Generate Excel content from records
|
|
67
|
+
def generate_xlsx(records, columns)
|
|
68
|
+
package = Axlsx::Package.new
|
|
69
|
+
workbook = package.workbook
|
|
70
|
+
|
|
71
|
+
workbook.add_worksheet(name: @model_name.pluralize) do |sheet|
|
|
72
|
+
# Header row with styling
|
|
73
|
+
header_style = workbook.styles.add_style(b: true, bg_color: 'DDDDDD')
|
|
74
|
+
sheet.add_row columns.map { |col| field_title(col.to_sym) }, style: header_style
|
|
75
|
+
|
|
76
|
+
# Data rows
|
|
77
|
+
records.each do |record|
|
|
78
|
+
sheet.add_row columns.map { |col| export_field_value(record, col.to_sym) }
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
package.to_stream.read
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Generate JSON content from records
|
|
86
|
+
def generate_json(records, columns)
|
|
87
|
+
records.map do |record|
|
|
88
|
+
columns.each_with_object({}) do |col, hash|
|
|
89
|
+
hash[col] = export_field_value(record, col.to_sym)
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Get plain text value for export (without HTML formatting)
|
|
95
|
+
def export_field_value(record, field_name)
|
|
96
|
+
# Check if this is a field configuration
|
|
97
|
+
config = self.class.field_configurations&.dig(field_name)
|
|
98
|
+
|
|
99
|
+
# Handle belongs_to relationships (foreign keys)
|
|
100
|
+
column = record.class.columns_hash[field_name.to_s]
|
|
101
|
+
if column&.type == :integer && field_name.to_s.end_with?('_id')
|
|
102
|
+
# This is a foreign key, get the associated record
|
|
103
|
+
association_name = field_name.to_s.gsub(/_id$/, '').to_sym
|
|
104
|
+
begin
|
|
105
|
+
related = record.public_send(association_name)
|
|
106
|
+
if related
|
|
107
|
+
# Try to find display field from config or use :name as default
|
|
108
|
+
reflection = record.class.reflect_on_association(association_name)
|
|
109
|
+
if reflection
|
|
110
|
+
display_field = config&.foreign_key_config&.dig(:display) || :name
|
|
111
|
+
result = related.public_send(display_field) rescue related.to_s
|
|
112
|
+
return result
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
return ''
|
|
116
|
+
rescue NoMethodError
|
|
117
|
+
# Not an association, treat as regular field
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Handle has_many relationships
|
|
122
|
+
if config&.has_many_config
|
|
123
|
+
related_records = record.public_send(field_name)
|
|
124
|
+
return related_records.count.to_s
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Handle has_one relationships
|
|
128
|
+
if config&.has_one_config
|
|
129
|
+
related = record.public_send(field_name)
|
|
130
|
+
display_field = config.has_one_config.dig(:display) || :name
|
|
131
|
+
return related ? related.public_send(display_field) : ''
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Handle HABTM relationships
|
|
135
|
+
if config&.habtm_config
|
|
136
|
+
related_records = record.public_send(field_name)
|
|
137
|
+
display_field = config.habtm_config.dig(:display_field) || :name
|
|
138
|
+
return related_records.map { |r| r.public_send(display_field) }.join(', ')
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Get the actual value
|
|
142
|
+
value = record.public_send(field_name)
|
|
143
|
+
|
|
144
|
+
# Handle dates/times
|
|
145
|
+
return value.strftime('%Y-%m-%d %H:%M') if value.is_a?(Time) || value.is_a?(DateTime)
|
|
146
|
+
return value.strftime('%Y-%m-%d') if value.is_a?(Date)
|
|
147
|
+
|
|
148
|
+
# Handle booleans
|
|
149
|
+
return value ? 'Yes' : 'No' if [TrueClass, FalseClass].include?(value.class)
|
|
150
|
+
|
|
151
|
+
# Handle nil
|
|
152
|
+
return '' if value.nil?
|
|
153
|
+
|
|
154
|
+
value.to_s
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Generate export filename
|
|
158
|
+
def export_filename(extension)
|
|
159
|
+
base = @model_name.pluralize.downcase.gsub(' ', '_')
|
|
160
|
+
suffix = search_active? ? '_filtered' : ''
|
|
161
|
+
"#{base}#{suffix}_#{Date.today.strftime('%Y-%m-%d')}.#{extension}"
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|