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,150 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ElaineCrud
|
|
4
|
+
# Methods for fetching records and determining column visibility
|
|
5
|
+
# Handles data retrieval, scoping, and column selection
|
|
6
|
+
module RecordFetching
|
|
7
|
+
extend ActiveSupport::Concern
|
|
8
|
+
|
|
9
|
+
included do
|
|
10
|
+
# Make determine_per_page available as a helper method in views
|
|
11
|
+
helper_method :determine_per_page
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
private
|
|
15
|
+
|
|
16
|
+
# Fetch all records for index view
|
|
17
|
+
# Can be overridden in subclasses for custom filtering/scoping
|
|
18
|
+
# @return [ActiveRecord::Relation] The records to display
|
|
19
|
+
def fetch_records
|
|
20
|
+
records = crud_model.all
|
|
21
|
+
|
|
22
|
+
# Apply parent filtering for has_many relationships
|
|
23
|
+
records = apply_has_many_filtering(records)
|
|
24
|
+
|
|
25
|
+
# Include all relationships to avoid N+1 queries
|
|
26
|
+
includes_list = get_all_relationship_includes
|
|
27
|
+
records = records.includes(includes_list) if includes_list.any?
|
|
28
|
+
|
|
29
|
+
# Apply search and filters
|
|
30
|
+
records = apply_search_and_filters(records)
|
|
31
|
+
|
|
32
|
+
# Apply sorting
|
|
33
|
+
records = apply_sorting(records)
|
|
34
|
+
|
|
35
|
+
# Apply pagination
|
|
36
|
+
apply_pagination(records)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Fetch records without pagination (for exports)
|
|
40
|
+
def fetch_records_for_export
|
|
41
|
+
records = crud_model.all
|
|
42
|
+
|
|
43
|
+
# Apply parent filtering for has_many relationships
|
|
44
|
+
records = apply_has_many_filtering(records)
|
|
45
|
+
|
|
46
|
+
# Include all relationships to avoid N+1 queries
|
|
47
|
+
includes_list = get_all_relationship_includes
|
|
48
|
+
records = records.includes(includes_list) if includes_list.any?
|
|
49
|
+
|
|
50
|
+
# Apply search and filters
|
|
51
|
+
records = apply_search_and_filters(records)
|
|
52
|
+
|
|
53
|
+
# Apply sorting
|
|
54
|
+
records = apply_sorting(records)
|
|
55
|
+
|
|
56
|
+
# Return without pagination
|
|
57
|
+
records
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Apply pagination to the record set
|
|
61
|
+
# @param records [ActiveRecord::Relation] The records to paginate
|
|
62
|
+
# @return [ActiveRecord::Relation] The paginated records
|
|
63
|
+
def apply_pagination(records)
|
|
64
|
+
# Update session with per_page preference if provided
|
|
65
|
+
if session.present? && params[:per_page].present?
|
|
66
|
+
session[:elaine_crud_per_page] = params[:per_page].to_i
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Determine records per page
|
|
70
|
+
per_page = determine_per_page
|
|
71
|
+
|
|
72
|
+
# Apply Kaminari pagination
|
|
73
|
+
records.page(params[:page]).per(per_page)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Determine how many records to show per page
|
|
77
|
+
# Priority: 1. URL param, 2. Session, 3. Default
|
|
78
|
+
# @return [Integer] Number of records per page
|
|
79
|
+
def determine_per_page
|
|
80
|
+
if params[:per_page].present?
|
|
81
|
+
params[:per_page].to_i
|
|
82
|
+
elsif session.present? && session[:elaine_crud_per_page].present?
|
|
83
|
+
session[:elaine_crud_per_page].to_i
|
|
84
|
+
else
|
|
85
|
+
25 # Default
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Find a single record by ID
|
|
90
|
+
# @return [ActiveRecord::Base] The found record
|
|
91
|
+
def find_record
|
|
92
|
+
crud_model.find(params[:id])
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Find a record by a specific ID (used for inline editing)
|
|
96
|
+
# @param id [Integer] The record ID
|
|
97
|
+
# @return [ActiveRecord::Base, nil] The found record or nil
|
|
98
|
+
def find_record_by_id(id)
|
|
99
|
+
crud_model.find(id)
|
|
100
|
+
rescue ActiveRecord::RecordNotFound
|
|
101
|
+
nil
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Determine which columns to display
|
|
105
|
+
# @return [Array<String>] List of column names to display
|
|
106
|
+
def determine_columns
|
|
107
|
+
# Get all potential columns (database columns + configured virtual fields)
|
|
108
|
+
db_columns = crud_model.column_names
|
|
109
|
+
virtual_fields = configured_virtual_fields
|
|
110
|
+
all_columns = (db_columns + virtual_fields).uniq
|
|
111
|
+
|
|
112
|
+
# Filter columns based on field configurations and new visibility rules
|
|
113
|
+
all_columns.select do |col|
|
|
114
|
+
field_config = field_config_for(col.to_sym)
|
|
115
|
+
|
|
116
|
+
if field_config&.visible == false
|
|
117
|
+
# Explicitly hidden via field configuration
|
|
118
|
+
false
|
|
119
|
+
elsif field_config&.visible == true
|
|
120
|
+
# Explicitly shown via field configuration (even if it ends with '_at')
|
|
121
|
+
true
|
|
122
|
+
elsif virtual_fields.include?(col)
|
|
123
|
+
# Virtual fields are shown if configured (like has_many relationships)
|
|
124
|
+
true
|
|
125
|
+
else
|
|
126
|
+
# Default behavior for DB columns: hide columns ending with '_at', show everything else
|
|
127
|
+
!col.end_with?('_at')
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Get list of configured virtual fields (non-database columns)
|
|
133
|
+
# @return [Array<String>] List of configured virtual field names
|
|
134
|
+
def configured_virtual_fields
|
|
135
|
+
return [] unless field_configurations
|
|
136
|
+
|
|
137
|
+
virtual_fields = []
|
|
138
|
+
field_configurations.each do |field_name, config|
|
|
139
|
+
field_name_str = field_name.to_s
|
|
140
|
+
|
|
141
|
+
# Include if it's not a database column but is configured
|
|
142
|
+
unless crud_model.column_names.include?(field_name_str)
|
|
143
|
+
virtual_fields << field_name_str
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
virtual_fields
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ElaineCrud
|
|
4
|
+
# Methods for handling ActiveRecord relationships (belongs_to, has_many)
|
|
5
|
+
# Provides eager loading, filtering, and parent context management
|
|
6
|
+
module RelationshipHandling
|
|
7
|
+
extend ActiveSupport::Concern
|
|
8
|
+
|
|
9
|
+
included do
|
|
10
|
+
# Make parent filter methods available as helper methods in views
|
|
11
|
+
helper_method :detect_parent_filters if respond_to?(:helper_method)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Get list of belongs_to associations to include for avoiding N+1 queries
|
|
15
|
+
# @return [Array<Symbol>] List of association names to include
|
|
16
|
+
def get_belongs_to_includes
|
|
17
|
+
return [] unless crud_model
|
|
18
|
+
|
|
19
|
+
includes = []
|
|
20
|
+
|
|
21
|
+
# Get all belongs_to relationships that are displayed
|
|
22
|
+
crud_model.reflections.each do |name, reflection|
|
|
23
|
+
next unless reflection.is_a?(ActiveRecord::Reflection::BelongsToReflection)
|
|
24
|
+
|
|
25
|
+
foreign_key = reflection.foreign_key.to_sym
|
|
26
|
+
|
|
27
|
+
# Include if this foreign key is in the displayed columns or configured
|
|
28
|
+
if determine_columns.include?(foreign_key.to_s) || field_configured?(foreign_key)
|
|
29
|
+
includes << name.to_sym
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
includes
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Get has_many relationships that need to be included
|
|
37
|
+
# @return [Array<Symbol>] List of association names to include
|
|
38
|
+
def get_has_many_includes
|
|
39
|
+
return [] unless crud_model
|
|
40
|
+
|
|
41
|
+
includes = []
|
|
42
|
+
|
|
43
|
+
crud_model.reflections.each do |name, reflection|
|
|
44
|
+
next unless reflection.is_a?(ActiveRecord::Reflection::HasManyReflection)
|
|
45
|
+
|
|
46
|
+
# Include if this relationship is displayed in columns or configured
|
|
47
|
+
if determine_columns.include?(name) || field_configured?(name.to_sym)
|
|
48
|
+
includes << name.to_sym
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
includes
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Get has_one relationships that need to be included
|
|
56
|
+
# @return [Array<Symbol>] List of association names to include
|
|
57
|
+
def get_has_one_includes
|
|
58
|
+
return [] unless crud_model
|
|
59
|
+
|
|
60
|
+
includes = []
|
|
61
|
+
|
|
62
|
+
crud_model.reflections.each do |name, reflection|
|
|
63
|
+
next unless reflection.is_a?(ActiveRecord::Reflection::HasOneReflection)
|
|
64
|
+
|
|
65
|
+
# Include if this relationship is displayed in columns or configured
|
|
66
|
+
if determine_columns.include?(name) || field_configured?(name.to_sym)
|
|
67
|
+
includes << name.to_sym
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
includes
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Get has_and_belongs_to_many relationships that need to be included
|
|
75
|
+
# @return [Array<Symbol>] List of association names to include
|
|
76
|
+
def get_habtm_includes
|
|
77
|
+
return [] unless crud_model
|
|
78
|
+
|
|
79
|
+
includes = []
|
|
80
|
+
|
|
81
|
+
crud_model.reflections.each do |name, reflection|
|
|
82
|
+
next unless reflection.is_a?(ActiveRecord::Reflection::HasAndBelongsToManyReflection)
|
|
83
|
+
|
|
84
|
+
# Include if this relationship is displayed in columns or configured
|
|
85
|
+
if determine_columns.include?(name) || field_configured?(name.to_sym)
|
|
86
|
+
includes << name.to_sym
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
includes
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Get list of associations to include for avoiding N+1 queries
|
|
94
|
+
# @return [Array<Symbol>] List of association names to include
|
|
95
|
+
def get_all_relationship_includes
|
|
96
|
+
return [] unless crud_model
|
|
97
|
+
|
|
98
|
+
includes = []
|
|
99
|
+
|
|
100
|
+
# Include belongs_to relationships (existing logic)
|
|
101
|
+
includes += get_belongs_to_includes
|
|
102
|
+
|
|
103
|
+
# Include has_many relationships that are displayed
|
|
104
|
+
includes += get_has_many_includes
|
|
105
|
+
|
|
106
|
+
# Include has_one relationships that are displayed
|
|
107
|
+
includes += get_has_one_includes
|
|
108
|
+
|
|
109
|
+
# Include has_and_belongs_to_many relationships that are displayed
|
|
110
|
+
includes += get_habtm_includes
|
|
111
|
+
|
|
112
|
+
includes.uniq
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Apply filtering based on parent relationship parameters
|
|
116
|
+
# @param records [ActiveRecord::Relation] The base query
|
|
117
|
+
# @return [ActiveRecord::Relation] Filtered query
|
|
118
|
+
def apply_has_many_filtering(records)
|
|
119
|
+
parent_filters = detect_parent_filters
|
|
120
|
+
|
|
121
|
+
parent_filters.each do |parent_field, parent_id|
|
|
122
|
+
next unless parent_id.present?
|
|
123
|
+
|
|
124
|
+
# Validate that this is a legitimate foreign key
|
|
125
|
+
if valid_parent_filter?(parent_field)
|
|
126
|
+
records = records.where(parent_field => parent_id)
|
|
127
|
+
|
|
128
|
+
# Set instance variables for UI context
|
|
129
|
+
set_parent_context(parent_field, parent_id)
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
records
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Detect parent filter parameters from URL
|
|
137
|
+
# @return [Hash] Hash of foreign_key => parent_id
|
|
138
|
+
def detect_parent_filters
|
|
139
|
+
parent_filters = {}
|
|
140
|
+
|
|
141
|
+
# Look for foreign key parameters in the URL
|
|
142
|
+
crud_model.reflections.each do |name, reflection|
|
|
143
|
+
next unless reflection.is_a?(ActiveRecord::Reflection::BelongsToReflection)
|
|
144
|
+
|
|
145
|
+
foreign_key = reflection.foreign_key.to_sym
|
|
146
|
+
param_value = params[foreign_key]
|
|
147
|
+
|
|
148
|
+
if param_value.present?
|
|
149
|
+
parent_filters[foreign_key] = param_value
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
parent_filters
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Validate that the parent filter is legitimate
|
|
157
|
+
# @param field_name [Symbol] The field name to validate
|
|
158
|
+
# @return [Boolean] True if valid foreign key column
|
|
159
|
+
def valid_parent_filter?(field_name)
|
|
160
|
+
crud_model.column_names.include?(field_name.to_s) &&
|
|
161
|
+
field_name.to_s.end_with?('_id')
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Set context for UI display
|
|
165
|
+
# @param parent_field [Symbol] The foreign key field
|
|
166
|
+
# @param parent_id [Integer] The parent record ID
|
|
167
|
+
def set_parent_context(parent_field, parent_id)
|
|
168
|
+
reflection = find_reflection_by_foreign_key(parent_field)
|
|
169
|
+
return unless reflection
|
|
170
|
+
|
|
171
|
+
begin
|
|
172
|
+
parent_record = reflection.klass.find(parent_id)
|
|
173
|
+
@parent_context = {
|
|
174
|
+
record: parent_record,
|
|
175
|
+
relationship_name: reflection.name,
|
|
176
|
+
foreign_key: parent_field,
|
|
177
|
+
model_class: reflection.klass
|
|
178
|
+
}
|
|
179
|
+
rescue ActiveRecord::RecordNotFound
|
|
180
|
+
@parent_context = {
|
|
181
|
+
error: "#{reflection.klass.name} with ID #{parent_id} not found",
|
|
182
|
+
foreign_key: parent_field,
|
|
183
|
+
model_class: reflection.klass
|
|
184
|
+
}
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# Find reflection by foreign key name
|
|
189
|
+
# @param foreign_key [Symbol] The foreign key field
|
|
190
|
+
# @return [ActiveRecord::Reflection::BelongsToReflection, nil] The reflection or nil
|
|
191
|
+
def find_reflection_by_foreign_key(foreign_key)
|
|
192
|
+
crud_model.reflections.values.find do |reflection|
|
|
193
|
+
reflection.is_a?(ActiveRecord::Reflection::BelongsToReflection) &&
|
|
194
|
+
reflection.foreign_key.to_sym == foreign_key.to_sym
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# Populate parent relationships from URL parameters
|
|
199
|
+
# @param record [ActiveRecord::Base] The record to populate
|
|
200
|
+
def populate_parent_relationships(record)
|
|
201
|
+
detect_parent_filters.each do |foreign_key, parent_id|
|
|
202
|
+
if valid_parent_filter?(foreign_key) && record.public_send(foreign_key).blank?
|
|
203
|
+
record.public_send("#{foreign_key}=", parent_id)
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# Determine redirect path after create based on context
|
|
209
|
+
# @return [String] The redirect path
|
|
210
|
+
def redirect_after_create_path
|
|
211
|
+
if @parent_context
|
|
212
|
+
# Redirect back to filtered index view
|
|
213
|
+
polymorphic_path([crud_model], @parent_context[:foreign_key] => @parent_context[:record].id)
|
|
214
|
+
else
|
|
215
|
+
# Standard redirect to show page
|
|
216
|
+
polymorphic_path(@record)
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ElaineCrud
|
|
4
|
+
# Extends ActionDispatch::Routing::Mapper to automatically add export routes
|
|
5
|
+
# to all resources that inherit from ElaineCrud::BaseController
|
|
6
|
+
module Routing
|
|
7
|
+
# Override the resources method to automatically add export action
|
|
8
|
+
# for controllers inheriting from ElaineCrud::BaseController
|
|
9
|
+
def resources(*args, &block)
|
|
10
|
+
super(*args) do
|
|
11
|
+
# Check if the controller uses ElaineCrud
|
|
12
|
+
resource_name = args.first
|
|
13
|
+
controller_name = "#{resource_name.to_s.camelize}Controller"
|
|
14
|
+
|
|
15
|
+
begin
|
|
16
|
+
controller_class = controller_name.constantize
|
|
17
|
+
# Add ElaineCrud routes if controller inherits from ElaineCrud::BaseController
|
|
18
|
+
if controller_class < ElaineCrud::BaseController
|
|
19
|
+
collection do
|
|
20
|
+
get :export # For CSV/Excel/JSON export
|
|
21
|
+
get :new_modal # For nested record creation in modal
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
rescue NameError
|
|
25
|
+
# Controller doesn't exist yet or doesn't use ElaineCrud - skip
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Allow custom block to be evaluated
|
|
29
|
+
instance_eval(&block) if block_given?
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ElaineCrud
|
|
4
|
+
# Methods for handling search and filtering of records
|
|
5
|
+
# Provides global text search and per-field filtering capabilities
|
|
6
|
+
module SearchAndFiltering
|
|
7
|
+
extend ActiveSupport::Concern
|
|
8
|
+
|
|
9
|
+
included do
|
|
10
|
+
# Make search/filter methods available as helper methods in views
|
|
11
|
+
helper_method :search_active?, :search_query, :filters if respond_to?(:helper_method)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Apply search and filters to records
|
|
15
|
+
# @param records [ActiveRecord::Relation] The base query
|
|
16
|
+
# @return [ActiveRecord::Relation] Filtered query
|
|
17
|
+
def apply_search_and_filters(records)
|
|
18
|
+
records = apply_global_search(records) if search_query.present?
|
|
19
|
+
records = apply_filters(records) if filters.present?
|
|
20
|
+
records
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Get search query from params
|
|
24
|
+
# @return [String, nil] The search term
|
|
25
|
+
def search_query
|
|
26
|
+
params[:search]
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Get filter parameters
|
|
30
|
+
# @return [Hash] Filter parameters
|
|
31
|
+
def filters
|
|
32
|
+
filter_params = params[:filter] || {}
|
|
33
|
+
# Convert ActionController::Parameters to Hash for compatibility
|
|
34
|
+
if filter_params.respond_to?(:to_unsafe_h)
|
|
35
|
+
filter_params.to_unsafe_h
|
|
36
|
+
elsif filter_params.respond_to?(:to_h)
|
|
37
|
+
filter_params.to_h
|
|
38
|
+
elsif filter_params.is_a?(Hash)
|
|
39
|
+
filter_params
|
|
40
|
+
else
|
|
41
|
+
# Handle malformed filter parameter (e.g., plain string)
|
|
42
|
+
{}
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Apply global text search across searchable columns
|
|
47
|
+
# @param records [ActiveRecord::Relation]
|
|
48
|
+
# @return [ActiveRecord::Relation]
|
|
49
|
+
def apply_global_search(records)
|
|
50
|
+
# Get searchable columns (string/text types)
|
|
51
|
+
searchable_columns = determine_searchable_columns
|
|
52
|
+
|
|
53
|
+
return records if searchable_columns.empty?
|
|
54
|
+
|
|
55
|
+
# Build OR conditions for each searchable column using Arel
|
|
56
|
+
# This prevents SQL injection in column names
|
|
57
|
+
table = crud_model.arel_table
|
|
58
|
+
search_pattern = "%#{search_query.downcase}%"
|
|
59
|
+
|
|
60
|
+
conditions = searchable_columns.map do |column|
|
|
61
|
+
table[column].lower.matches(search_pattern)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Combine all conditions with OR
|
|
65
|
+
records.where(conditions.reduce(:or))
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Apply individual field filters
|
|
69
|
+
# @param records [ActiveRecord::Relation]
|
|
70
|
+
# @return [ActiveRecord::Relation]
|
|
71
|
+
def apply_filters(records)
|
|
72
|
+
filters.each do |field, value|
|
|
73
|
+
next if value.blank?
|
|
74
|
+
|
|
75
|
+
# Skip special date range fields (handled separately)
|
|
76
|
+
next if field.to_s.end_with?('_from', '_to')
|
|
77
|
+
|
|
78
|
+
# Validate field is in the model
|
|
79
|
+
next unless valid_filter_field?(field)
|
|
80
|
+
|
|
81
|
+
records = apply_field_filter(records, field, value)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Apply date range filters
|
|
85
|
+
records = apply_date_range_filters(records)
|
|
86
|
+
|
|
87
|
+
records
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Apply filter for a specific field
|
|
91
|
+
# @param records [ActiveRecord::Relation]
|
|
92
|
+
# @param field [String, Symbol] Field name
|
|
93
|
+
# @param value [String, Array] Filter value
|
|
94
|
+
# @return [ActiveRecord::Relation]
|
|
95
|
+
def apply_field_filter(records, field, value)
|
|
96
|
+
column_type = get_column_type(field)
|
|
97
|
+
table = crud_model.arel_table
|
|
98
|
+
|
|
99
|
+
case column_type
|
|
100
|
+
when :string, :text
|
|
101
|
+
# Partial match for text fields using Arel
|
|
102
|
+
# This prevents SQL injection in field names
|
|
103
|
+
records.where(table[field].lower.matches("%#{value.downcase}%"))
|
|
104
|
+
when :boolean
|
|
105
|
+
# Exact match for booleans using Arel
|
|
106
|
+
records.where(table[field].eq(ActiveModel::Type::Boolean.new.cast(value)))
|
|
107
|
+
when :integer
|
|
108
|
+
# Handle foreign keys and integers using Arel
|
|
109
|
+
if value.is_a?(Array)
|
|
110
|
+
records.where(table[field].in(value))
|
|
111
|
+
else
|
|
112
|
+
records.where(table[field].eq(value))
|
|
113
|
+
end
|
|
114
|
+
else
|
|
115
|
+
# Default: exact match using Arel
|
|
116
|
+
records.where(table[field].eq(value))
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Apply date range filters
|
|
121
|
+
# @param records [ActiveRecord::Relation]
|
|
122
|
+
# @return [ActiveRecord::Relation]
|
|
123
|
+
def apply_date_range_filters(records)
|
|
124
|
+
date_fields = determine_date_columns
|
|
125
|
+
table = crud_model.arel_table
|
|
126
|
+
|
|
127
|
+
date_fields.each do |field|
|
|
128
|
+
from_key = "#{field}_from"
|
|
129
|
+
to_key = "#{field}_to"
|
|
130
|
+
|
|
131
|
+
# Use Arel to safely construct date range queries
|
|
132
|
+
# This prevents SQL injection in field names
|
|
133
|
+
if filters[from_key].present?
|
|
134
|
+
records = records.where(table[field].gteq(filters[from_key]))
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
if filters[to_key].present?
|
|
138
|
+
records = records.where(table[field].lteq(filters[to_key]))
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
records
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Determine which columns are searchable (string/text types)
|
|
146
|
+
# @return [Array<String>] Column names
|
|
147
|
+
def determine_searchable_columns
|
|
148
|
+
return [] unless crud_model
|
|
149
|
+
|
|
150
|
+
searchable = []
|
|
151
|
+
|
|
152
|
+
crud_model.columns.each do |col|
|
|
153
|
+
# Include string/text columns that are displayed
|
|
154
|
+
if [:string, :text].include?(col.type) &&
|
|
155
|
+
!%w[id created_at updated_at].include?(col.name) &&
|
|
156
|
+
determine_columns.include?(col.name)
|
|
157
|
+
|
|
158
|
+
# Check if field is configured as searchable
|
|
159
|
+
config = field_config_for(col.name.to_sym)
|
|
160
|
+
if config&.respond_to?(:searchable)
|
|
161
|
+
searchable << col.name if config.searchable
|
|
162
|
+
else
|
|
163
|
+
# Default: string/text fields are searchable
|
|
164
|
+
searchable << col.name
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
searchable
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# Determine which columns are filterable
|
|
173
|
+
# @return [Array<Hash>] Array of hashes with field info
|
|
174
|
+
def determine_filterable_columns
|
|
175
|
+
return [] unless crud_model
|
|
176
|
+
|
|
177
|
+
filterable = []
|
|
178
|
+
|
|
179
|
+
determine_columns.each do |col_name|
|
|
180
|
+
next if %w[id created_at updated_at].include?(col_name.to_s)
|
|
181
|
+
|
|
182
|
+
config = field_config_for(col_name.to_sym)
|
|
183
|
+
|
|
184
|
+
# Check if explicitly configured as non-filterable
|
|
185
|
+
if config&.respond_to?(:filterable)
|
|
186
|
+
next unless config.filterable # Skip if explicitly set to false
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# Default: all fields are filterable (matching search behavior)
|
|
190
|
+
filterable << {
|
|
191
|
+
name: col_name.to_sym,
|
|
192
|
+
type: infer_filter_type(col_name),
|
|
193
|
+
config: config
|
|
194
|
+
}
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
filterable
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# Get column type for a field
|
|
201
|
+
# @param field [String, Symbol] Field name
|
|
202
|
+
# @return [Symbol] Column type
|
|
203
|
+
def get_column_type(field)
|
|
204
|
+
column = crud_model.columns.find { |col| col.name == field.to_s }
|
|
205
|
+
column&.type || :string
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# Determine date columns for range filtering
|
|
209
|
+
# @return [Array<String>] Column names
|
|
210
|
+
def determine_date_columns
|
|
211
|
+
return [] unless crud_model
|
|
212
|
+
|
|
213
|
+
date_cols = []
|
|
214
|
+
|
|
215
|
+
crud_model.columns.each do |col|
|
|
216
|
+
if [:date, :datetime, :timestamp].include?(col.type) &&
|
|
217
|
+
determine_columns.include?(col.name)
|
|
218
|
+
|
|
219
|
+
# Check if explicitly configured as non-filterable
|
|
220
|
+
config = field_config_for(col.name.to_sym)
|
|
221
|
+
if config&.respond_to?(:filterable)
|
|
222
|
+
next unless config.filterable # Skip if explicitly set to false
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# Default: date fields are filterable
|
|
226
|
+
date_cols << col.name
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
date_cols
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
# Infer filter type from column type and configuration
|
|
234
|
+
# @param field_name [String, Symbol] Field name
|
|
235
|
+
# @return [Symbol] Filter type
|
|
236
|
+
def infer_filter_type(field_name)
|
|
237
|
+
column = crud_model.columns.find { |col| col.name == field_name.to_s }
|
|
238
|
+
return :text unless column
|
|
239
|
+
|
|
240
|
+
config = field_config_for(field_name.to_sym)
|
|
241
|
+
|
|
242
|
+
# Use configured filter type if available
|
|
243
|
+
return config.filter_type if config&.respond_to?(:filter_type) && config.filter_type
|
|
244
|
+
|
|
245
|
+
# Infer from column type
|
|
246
|
+
case column.type
|
|
247
|
+
when :boolean then :boolean
|
|
248
|
+
when :date, :datetime, :timestamp then :date_range
|
|
249
|
+
when :integer
|
|
250
|
+
# Check if it's a foreign key
|
|
251
|
+
if field_name.to_s.end_with?('_id')
|
|
252
|
+
:select
|
|
253
|
+
else
|
|
254
|
+
:text
|
|
255
|
+
end
|
|
256
|
+
else
|
|
257
|
+
:text
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
# Check if any search or filters are active
|
|
262
|
+
# @return [Boolean]
|
|
263
|
+
def search_active?
|
|
264
|
+
search_query.present? || filters.present?
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
# Get total count without filters (for "X of Y results" display)
|
|
268
|
+
# @return [Integer]
|
|
269
|
+
def total_unfiltered_count
|
|
270
|
+
@total_unfiltered_count ||= begin
|
|
271
|
+
# Build base query without search/filters
|
|
272
|
+
records = crud_model.all
|
|
273
|
+
records = apply_has_many_filtering(records)
|
|
274
|
+
records.count
|
|
275
|
+
end
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
# Validate that a field is safe to filter on
|
|
279
|
+
# @param field [String, Symbol] Field name
|
|
280
|
+
# @return [Boolean]
|
|
281
|
+
def valid_filter_field?(field)
|
|
282
|
+
crud_model.column_names.include?(field.to_s)
|
|
283
|
+
end
|
|
284
|
+
end
|
|
285
|
+
end
|