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