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