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,787 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ElaineCrud
4
+ module BaseHelper
5
+ # Display the value of a column for a record (legacy method for backward compatibility)
6
+ # This method can be overridden in the host application to customize display
7
+ #
8
+ # @param record [ActiveRecord::Base] The record to display
9
+ # @param column [String] The column name to display
10
+ # @return [String] The formatted value
11
+ def display_column_value(record, column)
12
+ # Handle virtual fields that don't exist on the model
13
+ unless record.respond_to?(column)
14
+ return content_tag(:span, 'Virtual field', class: 'text-gray-400')
15
+ end
16
+
17
+ value = record.public_send(column)
18
+
19
+ case value
20
+ when nil
21
+ content_tag(:span, '—', class: 'text-gray-400')
22
+ when true
23
+ content_tag(:span, '✓', class: 'text-green-600 font-bold')
24
+ when false
25
+ content_tag(:span, '✗', class: 'text-red-600 font-bold')
26
+ when Date, DateTime, Time
27
+ value.strftime('%m/%d/%Y')
28
+ else
29
+ truncate(value.to_s, length: 50)
30
+ end
31
+ end
32
+
33
+ # Display field value using new field configuration system
34
+ # @param record [ActiveRecord::Base] The record to display
35
+ # @param field_name [Symbol] The field name
36
+ # @param context [Symbol] The display context (:index or :show)
37
+ # @return [String] The formatted value
38
+ def display_field_value(record, field_name, context: :index)
39
+ config = controller.field_config_for(field_name)
40
+
41
+ # Handle has_many relationships
42
+ if config&.has_has_many? || is_has_many_relationship?(record, field_name)
43
+ return display_has_many_value(record, field_name, config)
44
+ end
45
+
46
+ # Handle has_one relationships
47
+ if config&.has_has_one? || is_has_one_relationship?(record, field_name)
48
+ return display_has_one_value(record, field_name, config)
49
+ end
50
+
51
+ # If field has custom display configuration, use it first (allows overriding defaults)
52
+ if config&.has_custom_display?
53
+ return config.render_display_value(record, controller)
54
+ # Handle has_and_belongs_to_many relationships with default display
55
+ elsif config&.has_habtm? || is_habtm_relationship?(record, field_name)
56
+ return display_habtm_field(record, field_name, config, context: context)
57
+ elsif config&.has_foreign_key?
58
+ # TODO: Implement foreign key display logic
59
+ # Should load the related record and format it according to foreign_key config
60
+ display_foreign_key_value(record, field_name, config)
61
+ else
62
+ # Fall back to default display logic
63
+ display_column_value(record, field_name.to_s)
64
+ end
65
+ end
66
+
67
+ private
68
+
69
+ # Display foreign key field value
70
+ # @param record [ActiveRecord::Base] The record to display
71
+ # @param field_name [Symbol] The field name
72
+ # @param config [FieldConfiguration] The field configuration
73
+ # @return [String] The formatted foreign key value
74
+ def display_foreign_key_value(record, field_name, config)
75
+ foreign_key_value = record.public_send(field_name)
76
+ return content_tag(:span, '—', class: 'text-gray-400') if foreign_key_value.blank?
77
+
78
+ foreign_key_config = config.foreign_key_config
79
+ target_model = foreign_key_config[:model]
80
+ return foreign_key_value.to_s unless target_model
81
+
82
+ # Load the related record
83
+ related_record = target_model.find_by(id: foreign_key_value)
84
+ return content_tag(:span, "Not found (ID: #{foreign_key_value})", class: 'text-red-400') unless related_record
85
+
86
+ # Apply display callback if configured
87
+ display_value = case foreign_key_config[:display]
88
+ when Symbol
89
+ related_record.respond_to?(foreign_key_config[:display]) ?
90
+ related_record.public_send(foreign_key_config[:display]) :
91
+ related_record.to_s
92
+ when Proc
93
+ foreign_key_config[:display].call(related_record)
94
+ else
95
+ related_record.to_s
96
+ end
97
+
98
+ # Auto-generate link to related record if it has a show route
99
+ # This can be disabled by setting linkable: false in foreign_key config
100
+ if should_link_foreign_key?(foreign_key_config, related_record)
101
+ render_foreign_key_link(related_record, display_value, target_model)
102
+ else
103
+ display_value.to_s.html_safe
104
+ end
105
+ rescue => e
106
+ # Graceful error handling
107
+ if Rails.env.development?
108
+ content_tag(:span, "Error: #{e.message}", class: 'text-red-500 text-xs')
109
+ else
110
+ foreign_key_value.to_s
111
+ end
112
+ end
113
+
114
+ # Check if foreign key should be rendered as a link
115
+ # @param foreign_key_config [Hash] The foreign key configuration
116
+ # @param related_record [ActiveRecord::Base] The related record
117
+ # @return [Boolean] Whether to render as a link
118
+ def should_link_foreign_key?(foreign_key_config, related_record)
119
+ # Check if explicitly disabled
120
+ return false if foreign_key_config[:linkable] == false
121
+
122
+ # Check if a show route exists for the model
123
+ model_name = related_record.class.name.underscore.pluralize
124
+ begin
125
+ # Try to generate the path - if it raises an error, route doesn't exist
126
+ url_helpers = Rails.application.routes.url_helpers
127
+ url_helpers.public_send("#{model_name.singularize}_path", related_record)
128
+ true
129
+ rescue NoMethodError, ActionController::UrlGenerationError
130
+ false
131
+ end
132
+ end
133
+
134
+ # Render foreign key as a clickable link
135
+ # @param related_record [ActiveRecord::Base] The related record
136
+ # @param display_value [String] The text to display
137
+ # @param target_model [Class] The target model class
138
+ # @return [String] HTML link
139
+ def render_foreign_key_link(related_record, display_value, target_model)
140
+ model_name = target_model.name.underscore.pluralize
141
+ path_helper = "#{model_name.singularize}_path"
142
+
143
+ link_to display_value,
144
+ url_for(controller: "/#{model_name}", action: :show, id: related_record.id),
145
+ class: "text-blue-600 hover:text-blue-800 underline font-medium",
146
+ data: { turbo: false } # Disable Turbo for cross-resource navigation
147
+ rescue => e
148
+ # Fallback to plain text if link generation fails
149
+ display_value.to_s.html_safe
150
+ end
151
+
152
+ # Display has_many relationship value
153
+ # @param record [ActiveRecord::Base] The record to display
154
+ # @param field_name [Symbol] The field name
155
+ # @param config [FieldConfiguration] The field configuration
156
+ # @return [String] The formatted has_many value
157
+ def display_has_many_value(record, field_name, config)
158
+ if config&.has_has_many?
159
+ # Use configuration to render
160
+ display_text = config.render_has_many_display(record, controller)
161
+ foreign_key = config.has_many_config[:foreign_key]
162
+ related_model_controller = config.has_many_config[:model].name.underscore.pluralize
163
+
164
+ link_to display_text,
165
+ url_for(controller: related_model_controller, action: :index, foreign_key => record.id),
166
+ class: "text-blue-600 hover:text-blue-800 underline",
167
+ data: { turbo_frame: "_top" }
168
+ else
169
+ # Fallback for auto-detected has_many
170
+ related_records = record.public_send(field_name)
171
+ count = related_records.respond_to?(:count) ? related_records.count : 0
172
+
173
+ if count > 0
174
+ related_model = field_name.to_s.classify
175
+ controller_name = field_name.to_s
176
+ foreign_key = "#{record.class.name.underscore}_id"
177
+
178
+ link_to "#{count} #{field_name.to_s.humanize.downcase}",
179
+ url_for(controller: controller_name, action: :index, foreign_key => record.id),
180
+ class: "text-blue-600 hover:text-blue-800 underline",
181
+ data: { turbo_frame: "_top" }
182
+ else
183
+ content_tag(:span, "No #{field_name.to_s.humanize.downcase}", class: 'text-gray-400')
184
+ end
185
+ end
186
+ rescue => e
187
+ Rails.logger.error "ElaineCrud: Error rendering has_many field #{field_name}: #{e.message}"
188
+ content_tag(:span, "Error loading relationship", class: 'text-red-400')
189
+ end
190
+
191
+ # Check if field is a has_one relationship
192
+ def is_has_one_relationship?(record, field_name)
193
+ reflection = record.class.reflections[field_name.to_s]
194
+ reflection&.is_a?(ActiveRecord::Reflection::HasOneReflection)
195
+ end
196
+
197
+ # Display has_one relationship value
198
+ def display_has_one_value(record, field_name, config)
199
+ if config&.has_has_one?
200
+ config.render_has_one_display(record, controller)
201
+ else
202
+ # Fallback for auto-detected has_one
203
+ related_record = record.public_send(field_name)
204
+
205
+ if related_record.nil?
206
+ content_tag(:span, '—', class: 'text-gray-400')
207
+ else
208
+ # Try to find a good display field
209
+ display_field = controller.send(:determine_display_field_for_model, related_record.class)
210
+ related_record.public_send(display_field).to_s
211
+ end
212
+ end
213
+ rescue => e
214
+ Rails.logger.error "ElaineCrud: Error rendering has_one field #{field_name}: #{e.message}"
215
+ content_tag(:span, "Error loading relationship", class: 'text-red-400')
216
+ end
217
+
218
+ # Check if field is a has_many relationship
219
+ def is_has_many_relationship?(record, field_name)
220
+ reflection = record.class.reflections[field_name.to_s]
221
+ reflection&.is_a?(ActiveRecord::Reflection::HasManyReflection)
222
+ end
223
+
224
+ # Check if field is a has_and_belongs_to_many relationship
225
+ def is_habtm_relationship?(record, field_name)
226
+ reflection = record.class.reflections[field_name.to_s]
227
+ reflection&.is_a?(ActiveRecord::Reflection::HasAndBelongsToManyReflection)
228
+ end
229
+
230
+ # Display HABTM field value - minimal implementation
231
+ # Applications should use display_as for custom rendering
232
+ # @param record [ActiveRecord::Base] The record to display
233
+ # @param field_name [Symbol] The field name
234
+ # @param config [FieldConfiguration] The field configuration
235
+ # @return [String] The formatted HABTM value
236
+ def display_habtm_field(record, field_name, config, context: :index)
237
+ related_records = record.public_send(field_name)
238
+
239
+ return content_tag(:span, "—", class: "text-gray-400") if related_records.empty?
240
+
241
+ # Get configuration
242
+ habtm_config = config&.habtm_config || {}
243
+ display_field = habtm_config[:display_field] || :name
244
+
245
+ # Determine the model class for the association
246
+ association_name = field_name.to_s.singularize
247
+ model_class = association_name.classify.constantize rescue nil
248
+
249
+ # In show context, display full list with links
250
+ if context == :show && model_class
251
+ items_html = related_records.map do |r|
252
+ display_value = r.public_send(display_field)
253
+ link_to(display_value, polymorphic_path(r), class: "text-blue-600 hover:text-blue-800 hover:underline", data: { turbo_frame: "_top" })
254
+ end
255
+
256
+ # Join with commas and proper spacing
257
+ safe_join(items_html, ", ")
258
+ else
259
+ # In index context, use compact display: comma-separated list of first few items
260
+ preview_items = related_records.first(3).map { |r| r.public_send(display_field) }
261
+ preview_text = preview_items.join(", ")
262
+ preview_text += ", ..." if related_records.count > 3
263
+
264
+ content_tag(:span, preview_text, class: "text-sm text-gray-900")
265
+ end
266
+ rescue => e
267
+ Rails.logger.error "ElaineCrud: Error rendering HABTM field #{field_name}: #{e.message}"
268
+ content_tag(:span, "Error loading relationships", class: 'text-red-400')
269
+ end
270
+
271
+ # Generate a human-readable title for the model
272
+ # @param model_class [Class] The ActiveRecord model class
273
+ # @return [String] Human-readable title
274
+ def model_title(model_class)
275
+ model_class.name.titleize
276
+ end
277
+
278
+ # Generate a human-readable field label (legacy method for backward compatibility)
279
+ # @param field_name [String, Symbol] The field name
280
+ # @return [String] Human-readable label
281
+ def field_label(field_name)
282
+ field_name.to_s.humanize
283
+ end
284
+
285
+ # Get field title using configuration system
286
+ # @param field_name [Symbol] The field name
287
+ # @return [String] The field title (configured or default)
288
+ def field_title(field_name)
289
+ controller.field_title(field_name)
290
+ end
291
+
292
+ # Get field description using configuration system
293
+ # @param field_name [Symbol] The field name
294
+ # @return [String, nil] The field description if configured
295
+ def field_description(field_name)
296
+ controller.field_description(field_name)
297
+ end
298
+
299
+
300
+
301
+ # Render form field using field configuration system
302
+ # @param form [ActionView::Helpers::FormBuilder] The form builder
303
+ # @param record [ActiveRecord::Base] The record being edited
304
+ # @param field_name [Symbol] The field name
305
+ # @return [String] HTML-safe form field
306
+ def render_form_field(form, record, field_name, has_error: false)
307
+ config = controller.field_config_for(field_name)
308
+
309
+ # If field is readonly, show the display value instead of an input
310
+ if field_readonly?(field_name)
311
+ content_tag(:div, display_field_value(record, field_name),
312
+ class: "px-3 py-2 bg-gray-100 border border-gray-500 text-gray-600")
313
+ # has_many associations are readonly in edit forms - show display value
314
+ elsif config&.has_has_many?
315
+ content_tag(:div, display_field_value(record, field_name),
316
+ class: "px-3 py-2 bg-gray-100 border border-gray-500 text-gray-600")
317
+ # has_one associations are readonly in edit forms - show display value
318
+ elsif config&.has_has_one?
319
+ content_tag(:div, display_field_value(record, field_name),
320
+ class: "px-3 py-2 bg-gray-100 border border-gray-500 text-gray-600")
321
+ # has_and_belongs_to_many associations - render checkboxes
322
+ elsif config&.has_habtm? || is_habtm_relationship?(record, field_name)
323
+ render_habtm_field(form, record, field_name, config, has_error: has_error)
324
+ elsif config&.has_edit_partial?
325
+ # Render using partial
326
+ render_partial_edit_field(config, record, form, field_name)
327
+ elsif config&.has_custom_edit?
328
+ # Render using custom edit callback
329
+ render_custom_edit_field(config, record, form, self)
330
+ elsif config&.has_options?
331
+ # Render options dropdown
332
+ render_options_field(form, field_name, config, has_error: has_error)
333
+ elsif config&.has_foreign_key?
334
+ # Render foreign key dropdown
335
+ render_foreign_key_field(form, field_name, config, has_error: has_error)
336
+ else
337
+ # Default form field based on ActiveRecord column type
338
+ render_default_form_field(form, record, field_name, has_error: has_error)
339
+ end
340
+ end
341
+
342
+ # Render default form field based on ActiveRecord column type
343
+ # @param form [ActionView::Helpers::FormBuilder] The form builder
344
+ # @param record [ActiveRecord::Base] The record
345
+ # @param field_name [Symbol] The field name
346
+ # @param has_error [Boolean] Whether the field has a validation error
347
+ # @return [String] HTML-safe form field
348
+ def render_default_form_field(form, record, field_name, has_error: false)
349
+ column = record.class.columns.find { |c| c.name == field_name.to_s }
350
+
351
+ # Error styling: red border for invalid fields, gray for valid
352
+ border_color = has_error ? "border-red-500 focus:border-red-700" : "border-gray-500 focus:border-gray-700"
353
+ field_class = "block w-full border #{border_color} text-sm bg-white px-3 py-2"
354
+
355
+ # Check if this is a foreign key (integer field ending in _id with a belongs_to association)
356
+ if column&.type == :integer && field_name.to_s.end_with?('_id')
357
+ reflection = find_belongs_to_reflection_for_foreign_key(record.class, field_name)
358
+ if reflection
359
+ # Render as select box for foreign key
360
+ return render_auto_foreign_key_field(form, field_name, reflection, has_error: has_error)
361
+ end
362
+ end
363
+
364
+ case column&.type
365
+ when :text
366
+ form.text_area(field_name, class: "#{field_class} resize-vertical", rows: 3)
367
+ when :boolean
368
+ form.check_box(field_name, class: "border border-gray-500 focus:border-gray-700 w-4 h-4")
369
+ when :date
370
+ form.date_field(field_name, class: field_class)
371
+ when :datetime, :timestamp
372
+ form.datetime_local_field(field_name, class: field_class)
373
+ when :integer, :decimal, :float
374
+ form.number_field(field_name, class: field_class)
375
+ else
376
+ form.text_field(field_name, class: field_class)
377
+ end
378
+ end
379
+
380
+ # Render partial edit field using configured partial
381
+ # @param config [FieldConfiguration] The field configuration
382
+ # @param record [ActiveRecord::Base] The record
383
+ # @param form [ActionView::Helpers::FormBuilder] The form builder
384
+ # @param field_name [Symbol] The field name
385
+ # @return [String] HTML-safe form field
386
+ def render_partial_edit_field(config, record, form, field_name)
387
+ field_value = record.public_send(field_name)
388
+
389
+ begin
390
+ render partial: config.edit_partial, locals: {
391
+ form: form,
392
+ record: record,
393
+ field_name: field_name,
394
+ field_value: field_value,
395
+ config: config
396
+ }
397
+ rescue => e
398
+ # Graceful error handling - show error in development
399
+ if Rails.env.development?
400
+ content_tag(:div, class: "text-red-500 text-xs border border-red-300 p-2 bg-red-50") do
401
+ concat(content_tag(:strong, "Partial Error: "))
402
+ concat("Could not render partial '#{config.edit_partial}': #{e.message}")
403
+ end
404
+ else
405
+ # Fallback to default form field in production
406
+ render_default_form_field(form, record, field_name)
407
+ end
408
+ end
409
+ end
410
+
411
+ # Render custom edit field using configuration callback
412
+ # @param config [FieldConfiguration] The field configuration
413
+ # @param record [ActiveRecord::Base] The record
414
+ # @param form [ActionView::Helpers::FormBuilder] The form builder
415
+ # @param view_context [ActionView::Base] The view context
416
+ # @return [String] HTML-safe form field
417
+ def render_custom_edit_field(config, record, form, view_context = nil)
418
+ field_value = record.public_send(config.field_name)
419
+
420
+ if config.edit_callback.is_a?(Symbol)
421
+ # Call method on controller instance
422
+ if controller.respond_to?(config.edit_callback, true)
423
+ controller.send(config.edit_callback, field_value, record, form)
424
+ else
425
+ # Fallback to default if method not found
426
+ render_default_form_field(form, record, config.field_name)
427
+ end
428
+ else
429
+ # Use the FieldConfiguration's render method which handles view context
430
+ config.render_edit_field(record, controller, form, view_context)
431
+ end
432
+ rescue => e
433
+ # Graceful error handling - show error in development
434
+ if Rails.env.development?
435
+ content_tag(:span, "Error: #{e.message}", class: "text-red-500 text-xs")
436
+ else
437
+ render_default_form_field(form, record, config.field_name)
438
+ end
439
+ end
440
+
441
+ # Render options dropdown field
442
+ # @param form [ActionView::Helpers::FormBuilder] The form builder
443
+ # @param field_name [Symbol] The field name
444
+ # @param config [FieldConfiguration] The field configuration
445
+ # @return [String] HTML-safe form field
446
+ def render_options_field(form, field_name, config, has_error: false)
447
+ border_color = has_error ? "border-red-500 focus:border-red-700" : "border-gray-500 focus:border-gray-700"
448
+ field_class = "block w-full border #{border_color} text-sm bg-white px-3 py-2"
449
+
450
+ # Handle both array and hash options
451
+ options = case config.options
452
+ when Array
453
+ config.options.map { |opt| [opt.to_s.humanize, opt] }
454
+ when Hash
455
+ config.options.to_a
456
+ else
457
+ []
458
+ end
459
+
460
+ form.select(field_name, options,
461
+ { include_blank: "Select..." },
462
+ { class: field_class })
463
+ end
464
+
465
+ # Render foreign key dropdown field
466
+ # @param form [ActionView::Helpers::FormBuilder] The form builder
467
+ # @param field_name [Symbol] The field name
468
+ # @param config [FieldConfiguration] The field configuration
469
+ # @return [String] HTML-safe form field
470
+ def render_foreign_key_field(form, field_name, config, has_error: false)
471
+ border_color = has_error ? "border-red-500 focus:border-red-700" : "border-gray-500 focus:border-gray-700"
472
+ field_class = "block w-full border #{border_color} text-sm bg-white px-3 py-2"
473
+
474
+ # Get the current value of the field to pre-select it
475
+ current_value = form.object.public_send(field_name)
476
+ options = foreign_key_options_for_field(field_name, current_value)
477
+
478
+ select_html = content_tag(:div, id: "#{field_name}_select_wrapper") do
479
+ form.select(field_name, options,
480
+ { include_blank: config.foreign_key_config[:null_option] || "Select..." },
481
+ { class: field_class })
482
+ end
483
+
484
+ # Check if nested_create is enabled
485
+ if config&.has_nested_create?
486
+ target_model = config.foreign_key_config[:model]
487
+ target_controller = target_model.name.underscore.pluralize
488
+
489
+ button_html = link_to "+ New #{target_model.name}",
490
+ url_for(controller: "/#{target_controller}", action: :new_modal, return_field: field_name, parent_model: controller.crud_model.name.underscore),
491
+ class: "inline-block mt-2 text-sm text-blue-600 hover:text-blue-800 underline",
492
+ data: { turbo_frame: "modal_content" }
493
+
494
+ select_html + button_html
495
+ else
496
+ select_html
497
+ end
498
+ end
499
+
500
+ # Get foreign key options for a specific field
501
+ # @param field_name [Symbol] The field name
502
+ # @param selected_value [Object] The value to pre-select in the dropdown
503
+ # @return [Array] Options suitable for select dropdown
504
+ def foreign_key_options_for_field(field_name, selected_value = nil)
505
+ config = controller.field_config_for(field_name)
506
+ return [] unless config&.has_foreign_key?
507
+
508
+ # Use the new foreign_key_options method from FieldConfiguration
509
+ options = config.foreign_key_options(controller)
510
+ options_for_select(options, selected_value)
511
+ end
512
+
513
+ private
514
+
515
+ # Render field options for select dropdowns
516
+ # @param field_name [Symbol] The field name
517
+ # @return [Array] Options array suitable for options_for_select
518
+ def field_options(field_name)
519
+ config = controller.field_config_for(field_name)
520
+ return [] unless config
521
+
522
+ if config.has_options?
523
+ case config.options
524
+ when Array
525
+ config.options.map { |opt| [opt.to_s.humanize, opt] }
526
+ when Hash
527
+ config.options.to_a
528
+ else
529
+ []
530
+ end
531
+ elsif config.has_foreign_key?
532
+ # This will be handled by foreign_key_options_for_field method
533
+ []
534
+ else
535
+ []
536
+ end
537
+ end
538
+
539
+ # Check if field has dropdown options
540
+ # @param field_name [Symbol] The field name
541
+ # @return [Boolean] True if field has options
542
+ def field_has_options?(field_name)
543
+ config = controller.field_config_for(field_name)
544
+ config&.has_options? || config&.has_foreign_key?
545
+ end
546
+
547
+ # Check if a field is readonly (helper method for views)
548
+ # @param field_name [Symbol] The field name
549
+ # @return [Boolean] True if field is readonly
550
+ def field_readonly?(field_name)
551
+ controller.field_readonly?(field_name)
552
+ end
553
+
554
+ # Get permitted attributes from controller (helper method for views)
555
+ # @return [Array<Symbol>] List of permitted attributes
556
+ def permitted_attributes
557
+ controller.permitted_attributes
558
+ end
559
+
560
+ # Get model name from controller (helper method for views)
561
+ # @return [String] The model name
562
+ def model_name
563
+ controller.model_name
564
+ end
565
+
566
+ # Generate a sort URL for a column
567
+ # @param column [Symbol] The column to sort by
568
+ # @return [String] URL with sort parameters
569
+ def sort_url_for(column)
570
+ current_column = controller.current_sort_column
571
+ current_direction = controller.current_sort_direction
572
+
573
+ # If clicking on the same column, toggle direction
574
+ if current_column.to_s == column.to_s
575
+ new_direction = controller.toggle_sort_direction(current_direction)
576
+ else
577
+ # New column, default to ascending
578
+ new_direction = :asc
579
+ end
580
+
581
+ # Preserve other parameters and add/update sort parameters
582
+ url_params = request.params.except(:action, :controller).merge({
583
+ sort: column,
584
+ direction: new_direction
585
+ })
586
+
587
+ url_for(url_params)
588
+ end
589
+
590
+ # Check if a column is currently being sorted
591
+ # @param column [Symbol] The column to check
592
+ # @return [Boolean] True if this column is being sorted
593
+ def column_sorted?(column)
594
+ controller.current_sort_column.to_s == column.to_s
595
+ end
596
+
597
+ # Get sort direction for a column
598
+ # @param column [Symbol] The column to check
599
+ # @return [Symbol, nil] The sort direction if this column is sorted
600
+ def column_sort_direction(column)
601
+ column_sorted?(column) ? controller.current_sort_direction : nil
602
+ end
603
+
604
+ # Generate sort direction indicator (arrow) for column header
605
+ # @param column [Symbol] The column to generate indicator for
606
+ # @return [String] HTML for sort indicator
607
+ def sort_indicator(column)
608
+ return '' unless column_sorted?(column)
609
+
610
+ direction = column_sort_direction(column)
611
+ case direction
612
+ when :asc
613
+ content_tag(:span, '↑', class: 'text-blue-600 font-bold ml-1')
614
+ when :desc
615
+ content_tag(:span, '↓', class: 'text-blue-600 font-bold ml-1')
616
+ else
617
+ ''
618
+ end
619
+ end
620
+
621
+ # Calculate layout structure for a record (helper method for views)
622
+ # @param content [ActiveRecord::Base] The record being displayed
623
+ # @param fields [Array<Symbol>] Array of field names to include in layout
624
+ # @return [Array<Array<Hash>>] Nested array layout structure
625
+ def calculate_layout(content, fields)
626
+ controller.calculate_layout(content, fields)
627
+ end
628
+
629
+ # Calculate layout header structure (helper method for views)
630
+ # @param fields [Array<Symbol>] Array of field names to include in layout
631
+ # @return [Array<Hash>] Array of header config objects
632
+ def calculate_layout_header(fields)
633
+ controller.calculate_layout_header(fields)
634
+ end
635
+
636
+ # Get the title for a header column
637
+ # @param header_config [Hash] Header config object
638
+ # @return [String] The title to display
639
+ def header_column_title(header_config)
640
+ if header_config[:title]
641
+ header_config[:title]
642
+ elsif header_config[:field_name]
643
+ field_title(header_config[:field_name])
644
+ else
645
+ ""
646
+ end
647
+ end
648
+
649
+ # Check if a header column is sortable
650
+ # @param header_config [Hash] Header config object
651
+ # @return [Boolean] True if column has a field_name and is sortable
652
+ def header_column_sortable?(header_config)
653
+ header_config[:field_name].present?
654
+ end
655
+
656
+ # Get grid column span for a field (for CSS grid layouts)
657
+ # @param field_name [Symbol] The field name
658
+ # @return [Integer] Number of columns this field should span
659
+ def field_grid_column_span(field_name)
660
+ config = controller.field_config_for(field_name)
661
+
662
+ # Check if field configuration specifies a column span
663
+ if config&.respond_to?(:grid_column_span) && config.grid_column_span
664
+ config.grid_column_span
665
+ else
666
+ # Default column span based on field type or configuration
667
+ case field_name.to_s
668
+ when /description|note|comment|content/i
669
+ # Text fields typically span more columns
670
+ 2
671
+ else
672
+ # Default single column
673
+ 1
674
+ end
675
+ end
676
+ end
677
+
678
+ # Get CSS classes for grid field layout
679
+ # @param field_name [Symbol] The field name
680
+ # @return [String] CSS classes for grid column styling
681
+ def field_grid_classes(field_name)
682
+ span = field_grid_column_span(field_name)
683
+ classes = []
684
+
685
+ # Add column span class if greater than 1
686
+ classes << "col-span-#{span}" if span > 1
687
+
688
+ classes.join(' ')
689
+ end
690
+
691
+ # Find belongs_to reflection for a foreign key field
692
+ # @param model_class [Class] The ActiveRecord model class
693
+ # @param foreign_key [Symbol] The foreign key field name
694
+ # @return [ActiveRecord::Reflection::BelongsToReflection, nil] The reflection or nil
695
+ def find_belongs_to_reflection_for_foreign_key(model_class, foreign_key)
696
+ model_class.reflections.values.find do |reflection|
697
+ reflection.is_a?(ActiveRecord::Reflection::BelongsToReflection) &&
698
+ reflection.foreign_key.to_sym == foreign_key.to_sym
699
+ end
700
+ end
701
+
702
+ # Render auto-detected foreign key field as select box
703
+ # @param form [ActionView::Helpers::FormBuilder] The form builder
704
+ # @param field_name [Symbol] The field name
705
+ # @param reflection [ActiveRecord::Reflection::BelongsToReflection] The belongs_to reflection
706
+ # @param has_error [Boolean] Whether the field has a validation error
707
+ # @return [String] HTML-safe form field
708
+ def render_auto_foreign_key_field(form, field_name, reflection, has_error: false)
709
+ border_color = has_error ? "border-red-500 focus:border-red-700" : "border-gray-500 focus:border-gray-700"
710
+ field_class = "block w-full border #{border_color} text-sm bg-white px-3 py-2"
711
+
712
+ begin
713
+ # Get the related model class
714
+ related_model = reflection.klass
715
+
716
+ # Determine display field for the related model
717
+ display_field = controller.send(:determine_display_field_for_model, related_model)
718
+
719
+ # Get all records for the dropdown
720
+ records = related_model.all.order(display_field)
721
+ options = records.map { |r| [r.public_send(display_field), r.id] }
722
+
723
+ # Get current value to pre-select
724
+ current_value = form.object.public_send(field_name)
725
+
726
+ form.select(field_name, options,
727
+ { include_blank: "Select..." },
728
+ { class: field_class })
729
+ rescue => e
730
+ # If there's an error loading the related model, fall back to number input
731
+ Rails.logger.warn "ElaineCrud: Could not render foreign key select for #{field_name}: #{e.message}" if Rails.env.development?
732
+ form.number_field(field_name, class: field_class)
733
+ end
734
+ end
735
+
736
+ # Render HABTM field for forms (checkboxes)
737
+ # @param form [ActionView::Helpers::FormBuilder] The form builder
738
+ # @param record [ActiveRecord::Base] The record being edited
739
+ # @param field_name [Symbol] The field name
740
+ # @param config [FieldConfiguration] The field configuration
741
+ # @param has_error [Boolean] Whether the field has a validation error
742
+ # @return [String] HTML-safe form field
743
+ def render_habtm_field(form, record, field_name, config, has_error: false)
744
+ reflection = record.class.reflections[field_name.to_s]
745
+ related_model = reflection.klass
746
+
747
+ # Get configuration or use defaults
748
+ habtm_config = config&.habtm_config || {}
749
+ display_field = habtm_config[:display_field] || :name
750
+
751
+ # Get all available records
752
+ all_records = related_model.all.order(display_field)
753
+
754
+ # Get currently selected IDs
755
+ selected_ids = record.public_send(field_name).pluck(:id)
756
+
757
+ # Add hidden field to ensure empty array is submitted when no checkboxes are checked
758
+ hidden_field = hidden_field_tag("#{record.class.name.underscore}[#{field_name.to_s.singularize}_ids][]", "", id: nil)
759
+
760
+ # Error styling for checkbox container
761
+ border_color = has_error ? "border-red-500" : "border-gray-300"
762
+
763
+ # Render checkboxes in a scrollable container
764
+ hidden_field.html_safe + content_tag(:div, class: "space-y-2 max-h-64 overflow-y-auto border #{border_color} rounded p-3 bg-white") do
765
+ all_records.map do |related_record|
766
+ checkbox_id = "#{record.class.name.underscore}_#{field_name.to_s.singularize}_ids_#{related_record.id}"
767
+
768
+ content_tag(:div, class: "flex items-center") do
769
+ label_text = related_record.public_send(display_field)
770
+
771
+ concat check_box_tag(
772
+ "#{record.class.name.underscore}[#{field_name.to_s.singularize}_ids][]",
773
+ related_record.id,
774
+ selected_ids.include?(related_record.id),
775
+ id: checkbox_id,
776
+ class: "h-4 w-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
777
+ )
778
+ concat label_tag(checkbox_id, label_text, class: "ml-2 text-sm text-gray-700")
779
+ end
780
+ end.join.html_safe
781
+ end
782
+ rescue => e
783
+ Rails.logger.error "ElaineCrud: Error rendering HABTM form field #{field_name}: #{e.message}"
784
+ content_tag(:span, "Error loading form field", class: 'text-red-400')
785
+ end
786
+ end
787
+ end