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,422 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ElaineCrud
|
|
4
|
+
# Configuration class for individual field customization
|
|
5
|
+
# Supports both hash-style and block-style DSL configuration
|
|
6
|
+
class FieldConfiguration
|
|
7
|
+
attr_accessor :field_name, :title, :description, :readonly, :default_value,
|
|
8
|
+
:display_callback, :edit_callback, :edit_partial, :options, :foreign_key_config,
|
|
9
|
+
:has_many_config, :has_one_config, :habtm_config, :visible, :grid_column_span,
|
|
10
|
+
:searchable, :filterable, :filter_type, :nested_create_config
|
|
11
|
+
|
|
12
|
+
def initialize(field_name, **options)
|
|
13
|
+
@field_name = field_name
|
|
14
|
+
|
|
15
|
+
# Set defaults
|
|
16
|
+
@title = options.fetch(:title, field_name.to_s.humanize)
|
|
17
|
+
@description = options.fetch(:description, nil)
|
|
18
|
+
@readonly = options.fetch(:readonly, false)
|
|
19
|
+
@default_value = options.fetch(:default_value, nil)
|
|
20
|
+
@display_callback = options.fetch(:display_as, nil)
|
|
21
|
+
@edit_callback = options.fetch(:edit_as, nil)
|
|
22
|
+
@edit_partial = options.fetch(:edit_partial, nil)
|
|
23
|
+
@options = options.fetch(:options, nil)
|
|
24
|
+
@foreign_key_config = options.fetch(:foreign_key, nil)
|
|
25
|
+
@has_many_config = options.fetch(:has_many, nil)
|
|
26
|
+
@has_one_config = options.fetch(:has_one, nil)
|
|
27
|
+
@habtm_config = options.fetch(:habtm, nil)
|
|
28
|
+
@visible = options.fetch(:visible, nil)
|
|
29
|
+
@grid_column_span = options.fetch(:grid_column_span, nil)
|
|
30
|
+
@searchable = options.fetch(:searchable, nil)
|
|
31
|
+
@filterable = options.fetch(:filterable, nil)
|
|
32
|
+
@filter_type = options.fetch(:filter_type, nil)
|
|
33
|
+
@nested_create_config = options.fetch(:nested_create, nil)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Block-style DSL methods
|
|
37
|
+
def title(value = nil)
|
|
38
|
+
return @title if value.nil?
|
|
39
|
+
@title = value
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def description(value = nil)
|
|
43
|
+
return @description if value.nil?
|
|
44
|
+
@description = value
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def readonly(value = nil)
|
|
48
|
+
return @readonly if value.nil?
|
|
49
|
+
@readonly = value
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def default_value(value = nil)
|
|
53
|
+
return @default_value if value.nil?
|
|
54
|
+
@default_value = value
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def display_as(callback = nil, &block)
|
|
58
|
+
return @display_callback if callback.nil? && block.nil?
|
|
59
|
+
@display_callback = callback || block
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def edit_as(callback = nil, &block)
|
|
63
|
+
return @edit_callback if callback.nil? && block.nil?
|
|
64
|
+
@edit_callback = callback || block
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def edit_partial(partial_path = nil)
|
|
68
|
+
return @edit_partial if partial_path.nil?
|
|
69
|
+
@edit_partial = partial_path
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def options(list = nil)
|
|
73
|
+
return @options if list.nil?
|
|
74
|
+
@options = list
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def foreign_key(**config)
|
|
78
|
+
return @foreign_key_config if config.empty?
|
|
79
|
+
@foreign_key_config = config
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def has_many(**config)
|
|
83
|
+
return @has_many_config if config.empty?
|
|
84
|
+
@has_many_config = config
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def has_one(**config)
|
|
88
|
+
return @has_one_config if config.empty?
|
|
89
|
+
@has_one_config = config
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def habtm(**config)
|
|
93
|
+
return @habtm_config if config.empty?
|
|
94
|
+
@habtm_config ||= {}
|
|
95
|
+
@habtm_config.merge!(config)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def visible(value = nil)
|
|
99
|
+
return @visible if value.nil?
|
|
100
|
+
@visible = value
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def grid_column_span(value = nil)
|
|
104
|
+
return @grid_column_span if value.nil?
|
|
105
|
+
@grid_column_span = value
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def searchable(value = nil)
|
|
109
|
+
return @searchable if value.nil?
|
|
110
|
+
@searchable = value
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def filterable(value = nil)
|
|
114
|
+
return @filterable if value.nil?
|
|
115
|
+
@filterable = value
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def filter_type(value = nil)
|
|
119
|
+
return @filter_type if value.nil?
|
|
120
|
+
@filter_type = value
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def nested_create(config = nil)
|
|
124
|
+
return @nested_create_config if config.nil?
|
|
125
|
+
# Allow nested_create true as shorthand - use true as the value since {} is not .present?
|
|
126
|
+
@nested_create_config = config == true ? true : config
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Helper methods for checking configuration state
|
|
130
|
+
def has_options?
|
|
131
|
+
@options.present?
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def has_foreign_key?
|
|
135
|
+
@foreign_key_config.present?
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def has_has_many?
|
|
139
|
+
@has_many_config.present?
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def has_has_one?
|
|
143
|
+
@has_one_config.present?
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def has_habtm?
|
|
147
|
+
@habtm_config.present?
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def has_custom_display?
|
|
151
|
+
@display_callback.present?
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def has_custom_edit?
|
|
155
|
+
@edit_callback.present?
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def has_edit_partial?
|
|
159
|
+
@edit_partial.present?
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def has_nested_create?
|
|
163
|
+
@nested_create_config.present?
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# Method to execute display callback
|
|
167
|
+
def render_display_value(record, controller_instance)
|
|
168
|
+
# For virtual fields (those that don't exist on the model), use nil as field_value
|
|
169
|
+
field_value = if record.respond_to?(@field_name)
|
|
170
|
+
record.public_send(@field_name)
|
|
171
|
+
else
|
|
172
|
+
nil
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
case @display_callback
|
|
176
|
+
when Symbol
|
|
177
|
+
# Call method on controller instance
|
|
178
|
+
if controller_instance.respond_to?(@display_callback, true)
|
|
179
|
+
result = controller_instance.send(@display_callback, field_value, record)
|
|
180
|
+
else
|
|
181
|
+
raise NoMethodError, "Display callback method '#{@display_callback}' not found on #{controller_instance.class.name}"
|
|
182
|
+
end
|
|
183
|
+
when Proc
|
|
184
|
+
# Call proc with field value and record
|
|
185
|
+
# Execute the lambda with the controller that has access to helpers
|
|
186
|
+
result = controller_instance.instance_exec(field_value, record, &@display_callback)
|
|
187
|
+
else
|
|
188
|
+
raise ArgumentError, "Display callback must be a Symbol or Proc, got #{@display_callback.class.name}"
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# Ensure result is HTML safe
|
|
192
|
+
result.respond_to?(:html_safe) ? result.html_safe : result.to_s.html_safe
|
|
193
|
+
rescue => e
|
|
194
|
+
# Graceful error handling - show the error in development but fallback in production
|
|
195
|
+
if Rails.env.development?
|
|
196
|
+
%Q{<span class="text-red-500 text-xs">Error: #{e.message}</span>}.html_safe
|
|
197
|
+
else
|
|
198
|
+
field_value.to_s.html_safe
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# Method to execute edit callback
|
|
203
|
+
def render_edit_field(record, controller_instance, form_builder = nil, view_context = nil)
|
|
204
|
+
field_value = record.public_send(@field_name)
|
|
205
|
+
|
|
206
|
+
case @edit_callback
|
|
207
|
+
when Symbol
|
|
208
|
+
# Call method on controller instance
|
|
209
|
+
if controller_instance.respond_to?(@edit_callback, true)
|
|
210
|
+
result = controller_instance.send(@edit_callback, field_value, record, form_builder)
|
|
211
|
+
else
|
|
212
|
+
raise NoMethodError, "Edit callback method '#{@edit_callback}' not found on #{controller_instance.class.name}"
|
|
213
|
+
end
|
|
214
|
+
when Proc
|
|
215
|
+
# Call proc with field value, record, and form builder
|
|
216
|
+
if view_context
|
|
217
|
+
# Execute the lambda in the view context so it has access to helpers
|
|
218
|
+
result = view_context.instance_exec(field_value, record, form_builder, &@edit_callback)
|
|
219
|
+
else
|
|
220
|
+
# Fallback to direct call for backward compatibility
|
|
221
|
+
result = @edit_callback.call(field_value, record, form_builder)
|
|
222
|
+
end
|
|
223
|
+
else
|
|
224
|
+
raise ArgumentError, "Edit callback must be a Symbol or Proc, got #{@edit_callback.class.name}"
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# Ensure result is HTML safe
|
|
228
|
+
result.respond_to?(:html_safe) ? result.html_safe : result.to_s.html_safe
|
|
229
|
+
rescue => e
|
|
230
|
+
# Graceful error handling - show the error in development but fallback in production
|
|
231
|
+
if Rails.env.development?
|
|
232
|
+
if view_context
|
|
233
|
+
view_context.content_tag(:span, "Error: #{e.message}", class: "text-red-500 text-xs")
|
|
234
|
+
else
|
|
235
|
+
%Q{<span class="text-red-500 text-xs">Error: #{e.message}</span>}.html_safe
|
|
236
|
+
end
|
|
237
|
+
else
|
|
238
|
+
# Fallback to basic text field
|
|
239
|
+
if form_builder
|
|
240
|
+
field_class = "block w-full border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 text-sm"
|
|
241
|
+
form_builder.text_field(@field_name, class: field_class)
|
|
242
|
+
else
|
|
243
|
+
field_value.to_s.html_safe
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
# Method to get foreign key options for dropdowns
|
|
249
|
+
def foreign_key_options(controller_instance)
|
|
250
|
+
return [] unless has_foreign_key?
|
|
251
|
+
|
|
252
|
+
# Validate required configuration
|
|
253
|
+
target_model = @foreign_key_config[:model]
|
|
254
|
+
return [] unless target_model
|
|
255
|
+
|
|
256
|
+
# Get records using scope if provided, otherwise all records
|
|
257
|
+
records = if @foreign_key_config[:scope]
|
|
258
|
+
@foreign_key_config[:scope].call
|
|
259
|
+
else
|
|
260
|
+
target_model.all
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
# Format options for select dropdown
|
|
264
|
+
options = records.map do |record|
|
|
265
|
+
display_value = case @foreign_key_config[:display]
|
|
266
|
+
when Symbol
|
|
267
|
+
record.respond_to?(@foreign_key_config[:display]) ?
|
|
268
|
+
record.public_send(@foreign_key_config[:display]) :
|
|
269
|
+
record.to_s
|
|
270
|
+
when Proc
|
|
271
|
+
@foreign_key_config[:display].call(record)
|
|
272
|
+
else
|
|
273
|
+
record.to_s
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
[display_value, record.id]
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
options
|
|
280
|
+
rescue => e
|
|
281
|
+
# Graceful error handling
|
|
282
|
+
if Rails.env.development?
|
|
283
|
+
[["Error: #{e.message}", ""]]
|
|
284
|
+
else
|
|
285
|
+
[]
|
|
286
|
+
end
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
# Method to get default value for new records
|
|
290
|
+
def resolve_default_value(controller_instance)
|
|
291
|
+
# TODO: Implement default value resolution
|
|
292
|
+
# 1. Return nil if @default_value is nil
|
|
293
|
+
# 2. If @default_value is a Proc or Lambda, call it with controller_instance as context
|
|
294
|
+
# 3. If @default_value is any other value, return it directly
|
|
295
|
+
# 4. Handle edge cases and ensure returned value is appropriate for the field type
|
|
296
|
+
return @default_value if @default_value.present? && !@default_value.respond_to?(:call)
|
|
297
|
+
return nil # Placeholder for callable defaults
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
# Helper method to determine if field should be shown in index
|
|
301
|
+
def show_in_index?
|
|
302
|
+
# TODO: Implement index visibility logic
|
|
303
|
+
# Default: show all fields except id and timestamps
|
|
304
|
+
# Could be configurable in future: @show_in_index boolean
|
|
305
|
+
!%w[id created_at updated_at].include?(@field_name.to_s)
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
# Helper method to determine if field should be shown in forms
|
|
309
|
+
def show_in_form?
|
|
310
|
+
# TODO: Implement form visibility logic
|
|
311
|
+
# Default: show all permitted params except readonly fields in edit mode
|
|
312
|
+
# Could be configurable in future: @show_in_form boolean
|
|
313
|
+
!@readonly
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
# Helper method to get field input type for default rendering
|
|
317
|
+
def default_input_type(column_type)
|
|
318
|
+
# TODO: Implement default input type mapping
|
|
319
|
+
# Map ActiveRecord column types to HTML input types:
|
|
320
|
+
# - :string, :text -> text_field / text_area
|
|
321
|
+
# - :integer, :decimal, :float -> number_field
|
|
322
|
+
# - :boolean -> check_box
|
|
323
|
+
# - :date -> date_field
|
|
324
|
+
# - :datetime, :timestamp -> datetime_local_field
|
|
325
|
+
# - etc.
|
|
326
|
+
case column_type
|
|
327
|
+
when :string then :text_field
|
|
328
|
+
when :text then :text_area
|
|
329
|
+
when :integer, :decimal, :float then :number_field
|
|
330
|
+
when :boolean then :check_box
|
|
331
|
+
when :date then :date_field
|
|
332
|
+
when :datetime, :timestamp then :datetime_local_field
|
|
333
|
+
else :text_field
|
|
334
|
+
end
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
# Get options for has_many relationship display
|
|
338
|
+
def has_many_related_records(controller_instance)
|
|
339
|
+
return [] unless has_has_many?
|
|
340
|
+
|
|
341
|
+
model_class = @has_many_config[:model]
|
|
342
|
+
foreign_key = @has_many_config[:foreign_key]
|
|
343
|
+
|
|
344
|
+
# This would be called from a parent record context
|
|
345
|
+
parent_record = controller_instance.instance_variable_get(:@record)
|
|
346
|
+
return [] unless parent_record
|
|
347
|
+
|
|
348
|
+
related_records = parent_record.public_send(@field_name)
|
|
349
|
+
|
|
350
|
+
# Apply scope if specified
|
|
351
|
+
if @has_many_config[:scope]
|
|
352
|
+
related_records = related_records.instance_eval(&@has_many_config[:scope])
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
related_records
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
# Get display value for has_many relationship
|
|
359
|
+
def render_has_many_display(record, controller_instance)
|
|
360
|
+
return "" unless has_has_many?
|
|
361
|
+
|
|
362
|
+
related_records = record.public_send(@field_name)
|
|
363
|
+
|
|
364
|
+
if @has_many_config[:show_count]
|
|
365
|
+
count = related_records.count
|
|
366
|
+
|
|
367
|
+
if @has_many_config[:max_preview_items] && @has_many_config[:max_preview_items] > 0
|
|
368
|
+
preview_records = related_records.limit(@has_many_config[:max_preview_items])
|
|
369
|
+
display_field = @has_many_config[:display] || :id
|
|
370
|
+
|
|
371
|
+
preview_text = preview_records.map do |rel_record|
|
|
372
|
+
begin
|
|
373
|
+
if display_field.is_a?(Proc)
|
|
374
|
+
display_field.call(rel_record)
|
|
375
|
+
else
|
|
376
|
+
result = rel_record.public_send(display_field)
|
|
377
|
+
result || "N/A"
|
|
378
|
+
end
|
|
379
|
+
rescue => e
|
|
380
|
+
Rails.logger.error "ElaineCrud: Error calling #{display_field} on #{rel_record.class.name}##{rel_record.id}: #{e.message}"
|
|
381
|
+
"Error"
|
|
382
|
+
end
|
|
383
|
+
end.join(", ")
|
|
384
|
+
|
|
385
|
+
"#{count} items#{count > 0 ? ": #{preview_text}" : ""}"
|
|
386
|
+
else
|
|
387
|
+
"#{count} items"
|
|
388
|
+
end
|
|
389
|
+
else
|
|
390
|
+
related_records.count.to_s
|
|
391
|
+
end
|
|
392
|
+
rescue => e
|
|
393
|
+
Rails.logger.error "ElaineCrud: Error rendering has_many display: #{e.message}"
|
|
394
|
+
"Error loading relationships"
|
|
395
|
+
end
|
|
396
|
+
|
|
397
|
+
# Get display value for has_one relationship
|
|
398
|
+
def render_has_one_display(record, controller_instance)
|
|
399
|
+
return "" unless has_has_one?
|
|
400
|
+
|
|
401
|
+
related_record = record.public_send(@field_name)
|
|
402
|
+
return "—" if related_record.nil?
|
|
403
|
+
|
|
404
|
+
display_field = @has_one_config[:display] || :id
|
|
405
|
+
|
|
406
|
+
begin
|
|
407
|
+
if display_field.is_a?(Proc)
|
|
408
|
+
display_field.call(related_record)
|
|
409
|
+
else
|
|
410
|
+
result = related_record.public_send(display_field)
|
|
411
|
+
result || "N/A"
|
|
412
|
+
end
|
|
413
|
+
rescue => e
|
|
414
|
+
Rails.logger.error "ElaineCrud: Error calling #{display_field} on #{related_record.class.name}##{related_record.id}: #{e.message}"
|
|
415
|
+
"Error"
|
|
416
|
+
end
|
|
417
|
+
rescue => e
|
|
418
|
+
Rails.logger.error "ElaineCrud: Error rendering has_one display: #{e.message}"
|
|
419
|
+
"Error loading relationship"
|
|
420
|
+
end
|
|
421
|
+
end
|
|
422
|
+
end
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ElaineCrud
|
|
4
|
+
# Instance methods for accessing and working with field configurations
|
|
5
|
+
# Provides runtime access to field metadata and rendering
|
|
6
|
+
module FieldConfigurationMethods
|
|
7
|
+
extend ActiveSupport::Concern
|
|
8
|
+
|
|
9
|
+
# Get configuration for a specific field
|
|
10
|
+
# @param field_name [Symbol] The field name
|
|
11
|
+
# @return [FieldConfiguration, nil] The configuration or nil if not configured
|
|
12
|
+
def field_config_for(field_name)
|
|
13
|
+
field_configurations&.dig(field_name.to_sym)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Get all configured field names
|
|
17
|
+
# @return [Array<Symbol>] List of configured field names
|
|
18
|
+
def configured_fields
|
|
19
|
+
field_configurations&.keys || []
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Check if a field has custom configuration
|
|
23
|
+
# @param field_name [Symbol] The field name
|
|
24
|
+
# @return [Boolean] True if field has custom configuration
|
|
25
|
+
def field_configured?(field_name)
|
|
26
|
+
field_config_for(field_name).present?
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Get display title for a field
|
|
30
|
+
# @param field_name [Symbol] The field name
|
|
31
|
+
# @return [String] The display title (configured or humanized)
|
|
32
|
+
def field_title(field_name)
|
|
33
|
+
config = field_config_for(field_name)
|
|
34
|
+
config&.title || field_name.to_s.humanize
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Get description for a field
|
|
38
|
+
# @param field_name [Symbol] The field name
|
|
39
|
+
# @return [String, nil] The field description if configured
|
|
40
|
+
def field_description(field_name)
|
|
41
|
+
field_config_for(field_name)&.description
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Check if a field is readonly
|
|
45
|
+
# @param field_name [Symbol] The field name
|
|
46
|
+
# @return [Boolean] True if field is readonly
|
|
47
|
+
def field_readonly?(field_name)
|
|
48
|
+
field_config_for(field_name)&.readonly || false
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Check if Turbo frames are disabled for this controller
|
|
52
|
+
# @return [Boolean] True if Turbo frames are disabled
|
|
53
|
+
def turbo_disabled?
|
|
54
|
+
disable_turbo_frames == true
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Render display value for a field
|
|
58
|
+
# @param record [ActiveRecord::Base] The record
|
|
59
|
+
# @param field_name [Symbol] The field name
|
|
60
|
+
# @return [String] HTML safe string for display
|
|
61
|
+
def render_field_display(record, field_name)
|
|
62
|
+
# Delegate to helper which has access to view context
|
|
63
|
+
helpers.display_field_value(record, field_name)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Render edit field for a form
|
|
67
|
+
# @param record [ActiveRecord::Base] The record
|
|
68
|
+
# @param field_name [Symbol] The field name
|
|
69
|
+
# @param form_builder [ActionView::Helpers::FormBuilder, nil] Optional form builder
|
|
70
|
+
# @return [String] HTML safe string for form field
|
|
71
|
+
def render_field_edit(record, field_name, form_builder = nil)
|
|
72
|
+
config = field_config_for(field_name)
|
|
73
|
+
|
|
74
|
+
return readonly_field_display(record, field_name) if field_readonly?(field_name)
|
|
75
|
+
|
|
76
|
+
if config&.has_custom_edit?
|
|
77
|
+
config.render_edit_field(record, self, form_builder)
|
|
78
|
+
else
|
|
79
|
+
# TODO: Implement fallback to default edit field logic
|
|
80
|
+
# Should generate appropriate form fields based on field type
|
|
81
|
+
# Should handle dropdowns for options, foreign key selects, etc.
|
|
82
|
+
"<!-- TODO: Default edit field for #{field_name} -->" # Placeholder
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Render readonly field display (for readonly fields in edit forms)
|
|
87
|
+
# @param record [ActiveRecord::Base] The record
|
|
88
|
+
# @param field_name [Symbol] The field name
|
|
89
|
+
# @return [String] HTML safe string for readonly display
|
|
90
|
+
def readonly_field_display(record, field_name)
|
|
91
|
+
# TODO: Implement readonly field display
|
|
92
|
+
# Should render the display value but in an edit form context
|
|
93
|
+
# Maybe with a different styling to indicate it's not editable
|
|
94
|
+
render_field_display(record, field_name) # Placeholder
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Instance method version of determine_display_field_for_model
|
|
98
|
+
# @param model_class [Class] The ActiveRecord model class
|
|
99
|
+
# @return [Symbol] The field to use for display
|
|
100
|
+
def determine_display_field_for_model(model_class)
|
|
101
|
+
# Common field names for display, in order of preference
|
|
102
|
+
display_candidates = [:name, :title, :display_name, :full_name, :label, :description]
|
|
103
|
+
|
|
104
|
+
# Check which columns exist
|
|
105
|
+
column_names = model_class.column_names.map(&:to_sym)
|
|
106
|
+
|
|
107
|
+
# Return the first matching candidate
|
|
108
|
+
display_field = display_candidates.find { |candidate| column_names.include?(candidate) }
|
|
109
|
+
|
|
110
|
+
# If none found, use the first string/text column that's not id, created_at, updated_at
|
|
111
|
+
unless display_field
|
|
112
|
+
string_columns = model_class.columns.select do |col|
|
|
113
|
+
[:string, :text].include?(col.type) &&
|
|
114
|
+
!%w[id created_at updated_at].include?(col.name)
|
|
115
|
+
end
|
|
116
|
+
display_field = string_columns.first&.name&.to_sym
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Final fallback to :id
|
|
120
|
+
display_field || :id
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Apply default values to a new record based on field configurations
|
|
124
|
+
# @param record [ActiveRecord::Base] The record to apply defaults to
|
|
125
|
+
def apply_field_defaults(record)
|
|
126
|
+
# TODO: Implement default value application
|
|
127
|
+
# Should iterate through field configurations and apply default_value if present
|
|
128
|
+
# Should handle both static values and proc/lambda callbacks
|
|
129
|
+
# Should only apply to new records (record.new_record?)
|
|
130
|
+
return record # Placeholder
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Debug method to help troubleshoot configuration issues
|
|
134
|
+
# Call this in your controller in development to see the configuration
|
|
135
|
+
def debug_configuration
|
|
136
|
+
return unless Rails.env.development?
|
|
137
|
+
|
|
138
|
+
puts "\n=== ElaineCrud Configuration Debug ==="
|
|
139
|
+
puts "Model: #{crud_model&.name || 'NOT SET'}"
|
|
140
|
+
puts "Permitted Attributes: #{permitted_attributes&.inspect || 'NOT SET'}"
|
|
141
|
+
puts "Field Configurations: #{field_configurations&.keys&.inspect || 'NONE'}"
|
|
142
|
+
|
|
143
|
+
if crud_model
|
|
144
|
+
reflections = crud_model.reflections.select { |_, r| r.is_a?(ActiveRecord::Reflection::BelongsToReflection) }
|
|
145
|
+
puts "Belongs_to relationships: #{reflections.keys.inspect}"
|
|
146
|
+
puts "Foreign keys: #{reflections.values.map(&:foreign_key).inspect}"
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
puts "====================================\n"
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ElaineCrud
|
|
4
|
+
# Methods for calculating and customizing layout and grid structure
|
|
5
|
+
# Handles column widths, row layouts, and field positioning
|
|
6
|
+
module LayoutCalculation
|
|
7
|
+
extend ActiveSupport::Concern
|
|
8
|
+
|
|
9
|
+
# Calculate layout structure for a specific record
|
|
10
|
+
# @param content [ActiveRecord::Base] The record being displayed
|
|
11
|
+
# @param fields [Array<Symbol>] Array of field names to include in layout
|
|
12
|
+
# @return [Array<Array<Hash>>] Nested array where first dimension is rows, second is columns
|
|
13
|
+
# Each column hash can contain: field_name, colspan, rowspan, and future properties
|
|
14
|
+
def calculate_layout(content, fields)
|
|
15
|
+
# Default implementation: single row with all fields, each taking 1 column and 1 row
|
|
16
|
+
row = fields.map do |field_name|
|
|
17
|
+
{
|
|
18
|
+
field_name: field_name,
|
|
19
|
+
colspan: 1,
|
|
20
|
+
rowspan: 1
|
|
21
|
+
}
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
[row] # Return single row
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Calculate layout header structure defining column sizes and titles
|
|
28
|
+
# @param fields [Array<Symbol>] Array of field names to include in layout
|
|
29
|
+
# @return [Array<Hash>] Array of header config objects with width, field_name, and/or title
|
|
30
|
+
# Each object can contain:
|
|
31
|
+
# - width: CSS width (required, e.g., "minmax(100px, 1fr)" or "25%")
|
|
32
|
+
# - field_name: Symbol of field to display and enable sorting (optional)
|
|
33
|
+
# - title: Custom column title, overrides field title (optional)
|
|
34
|
+
def calculate_layout_header(fields)
|
|
35
|
+
# Default implementation: flexible columns that can expand to fit content
|
|
36
|
+
# Using minmax() allows columns to grow beyond their base size when content requires it
|
|
37
|
+
# Note: Create a new array to avoid mutating the input
|
|
38
|
+
all_fields = fields + ["ROW-ACTIONS"]
|
|
39
|
+
|
|
40
|
+
all_fields.map do |field_name|
|
|
41
|
+
width = case field_name.to_s
|
|
42
|
+
when 'id' then "max-content"
|
|
43
|
+
when 'email' then "minmax(180px, 2fr)"
|
|
44
|
+
when 'ROW-ACTIONS' then "max-content"
|
|
45
|
+
else "minmax(100px, 1fr)"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
{
|
|
49
|
+
width: width,
|
|
50
|
+
field_name: field_name
|
|
51
|
+
}
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ElaineCrud
|
|
4
|
+
# Methods for handling strong parameters
|
|
5
|
+
# Processes form submissions and enforces permitted attributes
|
|
6
|
+
module ParameterHandling
|
|
7
|
+
extend ActiveSupport::Concern
|
|
8
|
+
|
|
9
|
+
private
|
|
10
|
+
|
|
11
|
+
# Strong parameters
|
|
12
|
+
# @return [ActionController::Parameters] Filtered parameters
|
|
13
|
+
def record_params
|
|
14
|
+
return {} unless permitted_attributes.present?
|
|
15
|
+
|
|
16
|
+
model_param_key = crud_model.name.underscore.to_sym
|
|
17
|
+
|
|
18
|
+
# Debug logging for development
|
|
19
|
+
if Rails.env.development?
|
|
20
|
+
Rails.logger.info "ElaineCrud: Looking for params under key '#{model_param_key}'"
|
|
21
|
+
Rails.logger.info "ElaineCrud: Available param keys: #{params.keys.inspect}"
|
|
22
|
+
Rails.logger.info "ElaineCrud: Permitted attributes: #{permitted_attributes.inspect}"
|
|
23
|
+
Rails.logger.info "ElaineCrud: Model name: #{crud_model.name}"
|
|
24
|
+
Rails.logger.info "ElaineCrud: Model underscore: #{crud_model.name.underscore}"
|
|
25
|
+
|
|
26
|
+
# Show the actual parameter structure
|
|
27
|
+
params.keys.each do |key|
|
|
28
|
+
if params[key].is_a?(ActionController::Parameters) || params[key].is_a?(Hash)
|
|
29
|
+
Rails.logger.info "ElaineCrud: params[#{key}] = #{params[key].inspect}"
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
if params[model_param_key].present?
|
|
35
|
+
filtered_params = params.require(model_param_key).permit(*permitted_attributes)
|
|
36
|
+
Rails.logger.info "ElaineCrud: Successfully filtered params: #{filtered_params.inspect}" if Rails.env.development?
|
|
37
|
+
filtered_params
|
|
38
|
+
else
|
|
39
|
+
Rails.logger.warn "ElaineCrud: No parameters found for model '#{model_param_key}'" if Rails.env.development?
|
|
40
|
+
Rails.logger.info "ElaineCrud: Available top-level param structure:" if Rails.env.development?
|
|
41
|
+
params.each do |key, value|
|
|
42
|
+
Rails.logger.info "ElaineCrud: #{key} => #{value.class} (#{value.is_a?(Hash) || value.is_a?(ActionController::Parameters) ? value.keys.inspect : value.inspect})"
|
|
43
|
+
end
|
|
44
|
+
{}
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|