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