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.
Files changed (55) hide show
  1. checksums.yaml +7 -0
  2. data/.rspec +3 -0
  3. data/LICENSE +21 -0
  4. data/README.md +225 -0
  5. data/Rakefile +9 -0
  6. data/TODO.md +496 -0
  7. data/app/controllers/elaine_crud/base_controller.rb +228 -0
  8. data/app/helpers/elaine_crud/base_helper.rb +787 -0
  9. data/app/helpers/elaine_crud/search_helper.rb +132 -0
  10. data/app/javascript/controllers/dropdown_controller.js +18 -0
  11. data/app/views/elaine_crud/base/_edit_row.html.erb +60 -0
  12. data/app/views/elaine_crud/base/_export_button.html.erb +88 -0
  13. data/app/views/elaine_crud/base/_foreign_key_select_refresh.html.erb +52 -0
  14. data/app/views/elaine_crud/base/_form.html.erb +45 -0
  15. data/app/views/elaine_crud/base/_form_fields.html.erb +45 -0
  16. data/app/views/elaine_crud/base/_index_table.html.erb +58 -0
  17. data/app/views/elaine_crud/base/_modal.html.erb +71 -0
  18. data/app/views/elaine_crud/base/_pagination.html.erb +110 -0
  19. data/app/views/elaine_crud/base/_per_page_selector.html.erb +30 -0
  20. data/app/views/elaine_crud/base/_search_bar.html.erb +75 -0
  21. data/app/views/elaine_crud/base/_show_details.html.erb +29 -0
  22. data/app/views/elaine_crud/base/_view_row.html.erb +96 -0
  23. data/app/views/elaine_crud/base/edit.html.erb +51 -0
  24. data/app/views/elaine_crud/base/index.html.erb +74 -0
  25. data/app/views/elaine_crud/base/new.html.erb +12 -0
  26. data/app/views/elaine_crud/base/new_modal.html.erb +37 -0
  27. data/app/views/elaine_crud/base/not_found.html.erb +49 -0
  28. data/app/views/elaine_crud/base/show.html.erb +32 -0
  29. data/docs/ARCHITECTURE.md +410 -0
  30. data/docs/CSS_GRID_LAYOUT.md +126 -0
  31. data/docs/DEMO.md +693 -0
  32. data/docs/DSL_EXAMPLES.md +313 -0
  33. data/docs/FOREIGN_KEY_EXAMPLE.rb +100 -0
  34. data/docs/FOREIGN_KEY_SUPPORT.md +197 -0
  35. data/docs/HAS_MANY_IMPLEMENTATION.md +154 -0
  36. data/docs/LAYOUT_EXAMPLES.md +301 -0
  37. data/docs/TROUBLESHOOTING.md +170 -0
  38. data/elaine_crud.gemspec +46 -0
  39. data/lib/elaine_crud/dsl_methods.rb +348 -0
  40. data/lib/elaine_crud/engine.rb +37 -0
  41. data/lib/elaine_crud/export_handling.rb +164 -0
  42. data/lib/elaine_crud/field_configuration.rb +422 -0
  43. data/lib/elaine_crud/field_configuration_methods.rb +152 -0
  44. data/lib/elaine_crud/layout_calculation.rb +55 -0
  45. data/lib/elaine_crud/parameter_handling.rb +48 -0
  46. data/lib/elaine_crud/record_fetching.rb +150 -0
  47. data/lib/elaine_crud/relationship_handling.rb +220 -0
  48. data/lib/elaine_crud/routing.rb +33 -0
  49. data/lib/elaine_crud/search_and_filtering.rb +285 -0
  50. data/lib/elaine_crud/sorting_concern.rb +65 -0
  51. data/lib/elaine_crud/version.rb +5 -0
  52. data/lib/elaine_crud.rb +25 -0
  53. data/lib/tasks/demo.rake +111 -0
  54. data/lib/tasks/spec.rake +26 -0
  55. 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