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,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ElaineCrud
4
+ # Helper methods for rendering search and filter UI components
5
+ module SearchHelper
6
+ # Render a filter field based on its type
7
+ # @param field_info [Hash] Field information with name, type, and config
8
+ # @return [String] HTML for the filter field
9
+ def render_filter_field(field_info)
10
+ field_name = field_info[:name]
11
+ filter_type = field_info[:type]
12
+ config = field_info[:config]
13
+
14
+ case filter_type
15
+ when :select
16
+ render_select_filter(field_name, config)
17
+ when :boolean
18
+ render_boolean_filter(field_name, config)
19
+ when :date_range
20
+ render_date_range_filter(field_name, config)
21
+ when :text
22
+ render_text_filter(field_name, config)
23
+ else
24
+ render_text_filter(field_name, config)
25
+ end
26
+ end
27
+
28
+ # Render a select dropdown filter
29
+ # @param field_name [Symbol] Field name
30
+ # @param config [FieldConfiguration, nil] Field configuration
31
+ # @return [String] HTML for select filter
32
+ def render_select_filter(field_name, config)
33
+ content_tag(:div, class: "flex flex-col") do
34
+ label = content_tag(:label, config&.title || field_name.to_s.humanize,
35
+ class: "text-sm font-medium text-gray-700 mb-1")
36
+
37
+ options = get_filter_options(field_name, config)
38
+ select = select_tag "filter[#{field_name}]",
39
+ options_for_select(options, @active_filters[field_name.to_s]),
40
+ include_blank: "All",
41
+ class: "block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
42
+
43
+ label + select
44
+ end
45
+ end
46
+
47
+ # Render a boolean filter (Yes/No/All)
48
+ # @param field_name [Symbol] Field name
49
+ # @param config [FieldConfiguration, nil] Field configuration
50
+ # @return [String] HTML for boolean filter
51
+ def render_boolean_filter(field_name, config)
52
+ content_tag(:div, class: "flex flex-col") do
53
+ label = content_tag(:label, config&.title || field_name.to_s.humanize,
54
+ class: "text-sm font-medium text-gray-700 mb-1")
55
+
56
+ select = select_tag "filter[#{field_name}]",
57
+ options_for_select([["All", ""], ["Yes", "true"], ["No", "false"]],
58
+ @active_filters[field_name.to_s]),
59
+ class: "block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
60
+
61
+ label + select
62
+ end
63
+ end
64
+
65
+ # Render a date range filter (from/to inputs)
66
+ # @param field_name [Symbol] Field name
67
+ # @param config [FieldConfiguration, nil] Field configuration
68
+ # @return [String] HTML for date range filter
69
+ def render_date_range_filter(field_name, config)
70
+ content_tag(:div, class: "flex flex-col") do
71
+ label = content_tag(:label, config&.title || field_name.to_s.humanize,
72
+ class: "text-sm font-medium text-gray-700 mb-1")
73
+
74
+ from_field = date_field_tag "filter[#{field_name}_from]",
75
+ @active_filters["#{field_name}_from"],
76
+ placeholder: "From",
77
+ class: "block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 text-sm"
78
+
79
+ to_field = date_field_tag "filter[#{field_name}_to]",
80
+ @active_filters["#{field_name}_to"],
81
+ placeholder: "To",
82
+ class: "block w-full mt-1 rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 text-sm"
83
+
84
+ label + from_field + to_field
85
+ end
86
+ end
87
+
88
+ # Render a text input filter
89
+ # @param field_name [Symbol] Field name
90
+ # @param config [FieldConfiguration, nil] Field configuration
91
+ # @return [String] HTML for text filter
92
+ def render_text_filter(field_name, config)
93
+ content_tag(:div, class: "flex flex-col") do
94
+ label = content_tag(:label, config&.title || field_name.to_s.humanize,
95
+ class: "text-sm font-medium text-gray-700 mb-1")
96
+
97
+ input = text_field_tag "filter[#{field_name}]",
98
+ @active_filters[field_name.to_s],
99
+ placeholder: "Filter by #{(config&.title || field_name.to_s.humanize).downcase}",
100
+ class: "block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
101
+
102
+ label + input
103
+ end
104
+ end
105
+
106
+ # Get filter options for a field
107
+ # @param field_name [Symbol] Field name
108
+ # @param config [FieldConfiguration, nil] Field configuration
109
+ # @return [Array] Array of [display, value] pairs for select options
110
+ def get_filter_options(field_name, config)
111
+ # If field has configured options, use those
112
+ if config&.has_options?
113
+ options = config.options
114
+ # Handle both array and hash formats
115
+ if options.is_a?(Hash)
116
+ options.to_a
117
+ else
118
+ options.map { |opt| [opt, opt] }
119
+ end
120
+ # If it's a foreign key, get options from related model
121
+ elsif config&.has_foreign_key?
122
+ config.foreign_key_options(controller)
123
+ # Otherwise, get distinct values from database
124
+ else
125
+ crud_model.distinct.pluck(field_name).compact.sort.map { |v| [v, v] }
126
+ end
127
+ rescue => e
128
+ Rails.logger.error "ElaineCrud: Error getting filter options for #{field_name}: #{e.message}"
129
+ []
130
+ end
131
+ end
132
+ end
@@ -0,0 +1,18 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // Dropdown controller for export menu
4
+ export default class extends Controller {
5
+ static targets = ["menu"]
6
+
7
+ toggle(event) {
8
+ event.stopPropagation()
9
+ this.menuTarget.classList.toggle("hidden")
10
+ }
11
+
12
+ hide(event) {
13
+ if (event && this.element.contains(event.target)) {
14
+ return
15
+ }
16
+ this.menuTarget.classList.add("hidden")
17
+ }
18
+ }
@@ -0,0 +1,60 @@
1
+ <%# Edit form wrapped in turbo-frame for seamless inline editing %>
2
+ <%# Calculate how many columns we have in the grid %>
3
+ <% header_layout = calculate_layout_header(columns.map(&:to_sym)) %>
4
+ <% total_columns = header_layout.length %>
5
+
6
+ <%# Wrap entire edit row in matching turbo-frame %>
7
+ <turbo-frame id="record_<%= record.id %>" class="contents">
8
+ <%# Edit form spans all columns %>
9
+ <%= form_with model: record, url: { action: :update, id: record.id }, method: :patch, class: "contents" do |form| %>
10
+
11
+ <%# Form content spanning all columns (including actions column) %>
12
+ <div class="bg-blue-50 border-2 border-blue-400 p-6 border-b" style="grid-column: 1 / -1;">
13
+ <%# Calculate layout for this record %>
14
+ <% record_layout = calculate_layout(record, columns.map(&:to_sym)) %>
15
+
16
+ <%# Render layout rows for editing %>
17
+ <% record_layout.each_with_index do |layout_row, row_index| %>
18
+ <%# Single CSS Grid container with synchronized column widths %>
19
+ <div class="grid gap-x-4 gap-y-4 <%= 'mb-4' unless row_index == record_layout.length - 1 %>" style="grid-template-columns: 1fr 2fr 1fr;">
20
+ <% layout_row.each_with_index do |column_config, col_index| %>
21
+ <% field_name = column_config[:field_name] %>
22
+ <% colspan = column_config[:colspan] || 1 %>
23
+ <% rowspan = column_config[:rowspan] || 1 %>
24
+
25
+ <%# Column 1: Field Name/Title %>
26
+ <div class="text-sm font-medium text-gray-700 py-3 <%= 'border-b border-blue-200' unless col_index == layout_row.length - 1 %> <%= 'col-span-' + colspan.to_s if colspan > 1 %>">
27
+ <%= field_title(field_name) %>
28
+ <% if record.errors[field_name].any? %>
29
+ <div class="text-red-500 font-normal text-xs mt-1">
30
+ <%= record.errors[field_name].first %>
31
+ </div>
32
+ <% end %>
33
+ </div>
34
+
35
+ <%# Column 2: Form Input %>
36
+ <div class="py-2 <%= 'border-b border-blue-200' unless col_index == layout_row.length - 1 %> <%= 'col-span-' + colspan.to_s if colspan > 1 %>">
37
+ <%= render_form_field(form, record, field_name) %>
38
+ </div>
39
+
40
+ <%# Column 3: Field Description %>
41
+ <div class="text-xs text-gray-500 py-3 <%= 'border-b border-blue-200' unless col_index == layout_row.length - 1 %> <%= 'col-span-' + colspan.to_s if colspan > 1 %>">
42
+ <% if field_description(field_name).present? %>
43
+ <%= field_description(field_name) %>
44
+ <% end %>
45
+ </div>
46
+ <% end %>
47
+ </div>
48
+ <% end %>
49
+
50
+ <%# Action buttons at the bottom %>
51
+ <div class="flex justify-end space-x-3 pt-6 border-t-2 border-blue-300 mt-6">
52
+ <%= form.submit "Save Changes",
53
+ class: "bg-green-600 hover:bg-green-700 text-white font-bold py-2 px-6 rounded shadow-md border border-green-700" %>
54
+ <%= link_to "Cancel",
55
+ url_for(action: :index, page: params[:page], per_page: params[:per_page]),
56
+ class: "bg-gray-200 hover:bg-gray-300 text-gray-800 font-bold py-2 px-6 rounded shadow-md border-2 border-gray-400" %>
57
+ </div>
58
+ </div>
59
+ <% end %>
60
+ </turbo-frame>
@@ -0,0 +1,88 @@
1
+ <%# Export button with dropdown menu for CSV/Excel/JSON formats %>
2
+ <div class="relative inline-block text-left" id="export-dropdown">
3
+ <button type="button"
4
+ onclick="toggleExportDropdown(event)"
5
+ class="inline-flex items-center px-4 py-2 bg-green-600 hover:bg-green-700 text-white font-medium rounded shadow-sm transition-colors duration-200">
6
+ <svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
7
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
8
+ </svg>
9
+ Export
10
+ <svg class="ml-2 h-4 w-4" fill="currentColor" viewBox="0 0 20 20">
11
+ <path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd"/>
12
+ </svg>
13
+ </button>
14
+
15
+ <div id="export-menu"
16
+ class="hidden absolute right-0 mt-2 w-48 rounded-md shadow-lg bg-white ring-1 ring-black ring-opacity-5 z-50">
17
+ <div class="py-1" role="menu" style="max-height: 300px; overflow-y: auto;">
18
+ <%
19
+ # Build export params, converting filter hash if present
20
+ csv_params = {action: :export, format: :csv}
21
+ csv_params[:search] = params[:search] if params[:search].present?
22
+ csv_params[:sort] = params[:sort] if params[:sort].present?
23
+ csv_params[:direction] = params[:direction] if params[:direction].present?
24
+ csv_params[:filter] = params[:filter].to_unsafe_h if params[:filter].present?
25
+ %>
26
+ <%= link_to(csv_params,
27
+ {class: "flex items-center px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 transition-colors duration-150 no-underline",
28
+ role: "menuitem",
29
+ style: "display: flex; text-decoration: none;"}) do %>
30
+ <svg class="w-4 h-4 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" style="flex-shrink: 0;">
31
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
32
+ </svg>
33
+ <span>CSV Format</span>
34
+ <% end %>
35
+
36
+ <%
37
+ xlsx_params = {action: :export, format: :xlsx}
38
+ xlsx_params[:search] = params[:search] if params[:search].present?
39
+ xlsx_params[:sort] = params[:sort] if params[:sort].present?
40
+ xlsx_params[:direction] = params[:direction] if params[:direction].present?
41
+ xlsx_params[:filter] = params[:filter].to_unsafe_h if params[:filter].present?
42
+ %>
43
+ <%= link_to(xlsx_params,
44
+ {class: "flex items-center px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 transition-colors duration-150 no-underline",
45
+ role: "menuitem",
46
+ style: "display: flex; text-decoration: none;"}) do %>
47
+ <svg class="w-4 h-4 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" style="flex-shrink: 0;">
48
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 10h18M3 14h18m-9-4v8m-7 0h14a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z"/>
49
+ </svg>
50
+ <span>Excel (XLSX)</span>
51
+ <% end %>
52
+
53
+ <%
54
+ json_params = {action: :export, format: :json}
55
+ json_params[:search] = params[:search] if params[:search].present?
56
+ json_params[:sort] = params[:sort] if params[:sort].present?
57
+ json_params[:direction] = params[:direction] if params[:direction].present?
58
+ json_params[:filter] = params[:filter].to_unsafe_h if params[:filter].present?
59
+ %>
60
+ <%= link_to(json_params,
61
+ {class: "flex items-center px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 transition-colors duration-150 no-underline",
62
+ role: "menuitem",
63
+ style: "display: flex; text-decoration: none;"}) do %>
64
+ <svg class="w-4 h-4 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" style="flex-shrink: 0;">
65
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4"/>
66
+ </svg>
67
+ <span>JSON Format</span>
68
+ <% end %>
69
+ </div>
70
+ </div>
71
+ </div>
72
+
73
+ <script>
74
+ function toggleExportDropdown(event) {
75
+ event.stopPropagation();
76
+ const menu = document.getElementById('export-menu');
77
+ menu.classList.toggle('hidden');
78
+ }
79
+
80
+ // Close dropdown when clicking outside
81
+ document.addEventListener('click', function(event) {
82
+ const dropdown = document.getElementById('export-dropdown');
83
+ const menu = document.getElementById('export-menu');
84
+ if (dropdown && menu && !dropdown.contains(event.target)) {
85
+ menu.classList.add('hidden');
86
+ }
87
+ });
88
+ </script>
@@ -0,0 +1,52 @@
1
+ <%# Partial to refresh foreign key dropdown after nested create %>
2
+ <div id="<%= field_name %>_select_wrapper">
3
+ <% if field_config&.has_foreign_key? %>
4
+ <%
5
+ # Generate options from the foreign key configuration
6
+ target_model = field_config.foreign_key_config[:model]
7
+ display_field = field_config.foreign_key_config[:display]
8
+ scope = field_config.foreign_key_config[:scope]
9
+
10
+ # Get records using scope if provided
11
+ records = scope ? target_model.instance_exec(&scope) : target_model.all
12
+
13
+ # Generate options based on display configuration
14
+ options = records.map do |record|
15
+ label = case display_field
16
+ when Symbol
17
+ record.public_send(display_field)
18
+ when Proc
19
+ display_field.call(record)
20
+ else
21
+ record.to_s
22
+ end
23
+ [label, record.id]
24
+ end
25
+ %>
26
+ <select class="block w-full border border-gray-500 focus:border-gray-700 text-sm bg-white px-3 py-2"
27
+ name="<%= parent_model %>[<%= field_name %>]"
28
+ id="<%= parent_model %>_<%= field_name %>">
29
+ <option value="">Select...</option>
30
+ <% options.each do |(label, value)| %>
31
+ <option value="<%= value %>" <%= 'selected' if value == selected_id %>>
32
+ <%= label %>
33
+ </option>
34
+ <% end %>
35
+ </select>
36
+ <% end %>
37
+ </div>
38
+
39
+ <script>
40
+ // Close modal after successful create and refresh
41
+ ElaineCrud.closeModal();
42
+
43
+ // Show success notification
44
+ const notification = document.createElement('div');
45
+ notification.className = 'fixed top-4 right-4 bg-green-500 text-white px-6 py-3 rounded shadow-lg z-50';
46
+ notification.textContent = '<%= @model_name %> created successfully!';
47
+ document.body.appendChild(notification);
48
+
49
+ setTimeout(() => {
50
+ notification.remove();
51
+ }, 3000);
52
+ </script>
@@ -0,0 +1,45 @@
1
+ <%# Shared form partial for new and edit views %>
2
+ <% form_url = record.persisted? ? { action: :update, id: record.id } : { action: :create } %>
3
+ <%= form_with model: record, url: form_url, local: true, class: "space-y-6" do |form| %>
4
+
5
+ <%# Handle parent context for has_many relationships %>
6
+ <% if @parent_context && @parent_context[:record] %>
7
+ <%= form.hidden_field @parent_context[:foreign_key], value: @parent_context[:record].id %>
8
+
9
+ <div class="mb-4 p-3 bg-gray-50 rounded-md">
10
+ <p class="text-sm text-gray-600">
11
+ Creating <%= record.class.name.underscore.humanize.downcase %> for
12
+ <strong><%= @parent_context[:model_class].name %>:</strong>
13
+ <%= @parent_context[:record].public_send(controller.send(:determine_display_field_for_model, @parent_context[:model_class])) %>
14
+ </p>
15
+ </div>
16
+ <% end %>
17
+
18
+ <%# Display validation errors if any %>
19
+ <% if record.errors.any? %>
20
+ <div class="bg-red-50 border border-red-200 rounded-md p-4 mb-4">
21
+ <h3 class="text-sm font-medium text-red-800">
22
+ <%= pluralize(record.errors.count, "error") %> prohibited this <%= record.class.name.underscore.humanize.downcase %> from being saved:
23
+ </h3>
24
+ <ul class="mt-2 text-sm text-red-700 list-disc list-inside">
25
+ <% record.errors.full_messages.each do |message| %>
26
+ <li><%= message %></li>
27
+ <% end %>
28
+ </ul>
29
+ </div>
30
+ <% end %>
31
+
32
+ <%# Render form fields using shared partial %>
33
+ <div class="space-y-4">
34
+ <%= render partial: 'elaine_crud/base/form_fields',
35
+ locals: { form: form, record: record } %>
36
+ </div>
37
+
38
+ <div class="flex justify-end space-x-3">
39
+ <%= link_to "Cancel",
40
+ url_for(action: :index),
41
+ class: "bg-gray-300 hover:bg-gray-400 text-gray-800 font-bold py-2 px-4 rounded" %>
42
+ <%= form.submit submit_text,
43
+ class: "bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded" %>
44
+ </div>
45
+ <% end %>
@@ -0,0 +1,45 @@
1
+ <%# Shared form fields partial for reuse in modal and regular forms %>
2
+ <% permitted_attributes.each do |field_name| %>
3
+ <%# Handle hash entries (like {tag_ids: []}) - convert to association name %>
4
+ <% original_param_name = field_name %>
5
+ <% if field_name.is_a?(Hash) %>
6
+ <%# Extract the key and convert tag_ids -> tags %>
7
+ <% original_param_name = field_name.keys.first.to_sym %>
8
+ <% field_name = field_name.keys.first.to_s.sub(/_ids$/, '').pluralize.to_sym %>
9
+ <% end %>
10
+
11
+ <%# Determine which field name to check for errors %>
12
+ <% error_field_name = field_name %>
13
+ <% if original_param_name.is_a?(Symbol) && original_param_name.to_s.end_with?('_ids') %>
14
+ <% param_field_name = original_param_name.to_s.sub(/_ids$/, '').pluralize.to_sym %>
15
+ <% error_field_name = record.errors[param_field_name].any? ? param_field_name : original_param_name %>
16
+ <% end %>
17
+
18
+ <% has_error = record.errors[error_field_name].any? %>
19
+
20
+ <div class="form-group">
21
+ <label for="<%= "#{record.class.name.underscore}_#{field_name}" %>"
22
+ class="block text-sm font-medium text-gray-700 mb-1">
23
+ <%= field_title(field_name) %>
24
+ <% if field_readonly?(field_name) %>
25
+ <span class="text-xs text-gray-500">(read-only)</span>
26
+ <% end %>
27
+ </label>
28
+
29
+ <%= render_form_field(form, record, field_name, has_error: has_error) %>
30
+
31
+ <%# Display field-level error message %>
32
+ <% if has_error %>
33
+ <p class="text-sm text-red-600 mt-1">
34
+ <%= record.errors[error_field_name].first %>
35
+ </p>
36
+ <% end %>
37
+
38
+ <%# Display field description (help text) %>
39
+ <% if field_description(field_name).present? %>
40
+ <p class="text-xs text-gray-500 mt-1">
41
+ <%= field_description(field_name) %>
42
+ </p>
43
+ <% end %>
44
+ </div>
45
+ <% end %>
@@ -0,0 +1,58 @@
1
+ <%# Reusable table grid component for displaying records in a grid layout %>
2
+ <%# Required locals: records (collection), columns (array of symbols) %>
3
+ <%# Optional locals: show_actions (boolean, default: true) %>
4
+
5
+ <% show_actions = local_assigns.fetch(:show_actions, true) %>
6
+
7
+ <% if records.any? %>
8
+ <%# Calculate layout header to determine grid structure %>
9
+ <% header_layout = calculate_layout_header(columns.map(&:to_sym)) %>
10
+
11
+ <%# Remove ROW-ACTIONS column if show_actions is false %>
12
+ <% unless show_actions %>
13
+ <% header_layout = header_layout.reject { |config| config[:field_name].to_s == 'ROW-ACTIONS' } %>
14
+ <% end %>
15
+
16
+ <% grid_template = header_layout.map { |config| config[:width] }.join(' ') %>
17
+
18
+ <div class="overflow-x-auto">
19
+ <%# Main grid container - grid template defined once here for entire table %>
20
+ <div class="bg-white shadow-md border border-gray-300 inline-block min-w-full grid" style="grid-template-columns: <%= grid_template %>;">
21
+ <%# Header row - render all headers from layout %>
22
+ <% header_layout.each do |header_config| %>
23
+ <% if header_config[:field_name].to_s == 'ROW-ACTIONS' %>
24
+ <%# Actions column header %>
25
+ <div class="px-3 py-3 text-xs font-medium text-gray-700 uppercase tracking-wider border-b-2 border-gray-200 bg-gray-100">
26
+ Actions
27
+ </div>
28
+ <% else %>
29
+ <%# Regular column header %>
30
+ <div class="px-3 py-3 text-xs font-medium text-gray-700 uppercase tracking-wider border-r border-b-2 border-gray-200 bg-gray-100">
31
+ <% if header_column_sortable?(header_config) %>
32
+ <%= link_to sort_url_for(header_config[:field_name]), class: "flex items-center hover:text-blue-600 transition-colors duration-200" do %>
33
+ <span><%= header_column_title(header_config) %></span>
34
+ <%= sort_indicator(header_config[:field_name]).html_safe %>
35
+ <% end %>
36
+ <% else %>
37
+ <span><%= header_column_title(header_config) %></span>
38
+ <% end %>
39
+ </div>
40
+ <% end %>
41
+ <% end %>
42
+
43
+ <%# Records - each cell is a direct grid child %>
44
+ <% records.each_with_index do |record, index| %>
45
+ <% record_id = "record_#{record.id}" %>
46
+ <% row_bg_class = index.even? ? "bg-white" : "bg-gray-50" %>
47
+ <% is_last_record = index == records.length - 1 %>
48
+ <%# Render normal view row %>
49
+ <%= render 'elaine_crud/base/view_row', record: record, columns: columns, row_bg_class: row_bg_class, record_id: record_id, is_last_record: is_last_record, show_actions: show_actions %>
50
+ <% end %>
51
+ </div>
52
+ </div>
53
+ <% else %>
54
+ <div class="bg-gray-50 border border-gray-200 rounded-lg p-8 text-center">
55
+ <h3 class="text-lg font-medium text-gray-900 mb-2">No records found</h3>
56
+ <p class="text-gray-600">No data to display.</p>
57
+ </div>
58
+ <% end %>
@@ -0,0 +1,71 @@
1
+ <%# Generic modal component for nested record creation %>
2
+ <div id="elaine-modal" class="hidden fixed inset-0 z-50 overflow-y-auto" aria-labelledby="modal-title" role="dialog" aria-modal="true">
3
+ <!-- Background overlay -->
4
+ <div class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" aria-hidden="true" onclick="ElaineCrud.closeModal()"></div>
5
+
6
+ <!-- Modal panel -->
7
+ <div class="flex min-h-full items-center justify-center p-4">
8
+ <div class="relative bg-white rounded-lg shadow-xl max-w-2xl w-full max-h-[90vh] overflow-hidden">
9
+ <!-- Close button -->
10
+ <button type="button"
11
+ onclick="ElaineCrud.closeModal()"
12
+ class="absolute top-4 right-4 text-gray-400 hover:text-gray-500 z-10">
13
+ <span class="sr-only">Close</span>
14
+ <svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
15
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
16
+ </svg>
17
+ </button>
18
+
19
+ <!-- Modal content loaded via Turbo Frame -->
20
+ <div class="overflow-y-auto max-h-[85vh]">
21
+ <%= turbo_frame_tag "modal_content" do %>
22
+ <div class="p-6">
23
+ <p class="text-gray-500">Loading...</p>
24
+ </div>
25
+ <% end %>
26
+ </div>
27
+ </div>
28
+ </div>
29
+ </div>
30
+
31
+ <script>
32
+ // Global ElaineCrud namespace for modal functions
33
+ window.ElaineCrud = window.ElaineCrud || {};
34
+
35
+ ElaineCrud.openModal = function() {
36
+ const modal = document.getElementById('elaine-modal');
37
+ if (modal) {
38
+ modal.classList.remove('hidden');
39
+ document.body.style.overflow = 'hidden';
40
+ }
41
+ };
42
+
43
+ ElaineCrud.closeModal = function() {
44
+ const modal = document.getElementById('elaine-modal');
45
+ if (modal) {
46
+ modal.classList.add('hidden');
47
+ document.body.style.overflow = '';
48
+
49
+ // Clear modal content
50
+ const frame = document.getElementById('modal_content');
51
+ if (frame) {
52
+ frame.innerHTML = '<div class="p-6"><p class="text-gray-500">Loading...</p></div>';
53
+ }
54
+ }
55
+ };
56
+
57
+ // Listen for successful turbo form submissions in modal
58
+ document.addEventListener('turbo:submit-end', function(event) {
59
+ if (event.detail.success && event.target.dataset.nestedCreate) {
60
+ // Close modal on successful submission
61
+ ElaineCrud.closeModal();
62
+ }
63
+ });
64
+
65
+ // Open modal when turbo frame loads in modal
66
+ document.addEventListener('turbo:frame-load', function(event) {
67
+ if (event.target.id === 'modal_content') {
68
+ ElaineCrud.openModal();
69
+ }
70
+ });
71
+ </script>