infotable 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 (58) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +1043 -0
  4. data/Rakefile +6 -0
  5. data/app/assets/javascripts/infotable/controllers/drawer_controller.js +1412 -0
  6. data/app/assets/javascripts/infotable/controllers/table_controller.js +921 -0
  7. data/app/assets/javascripts/infotable/index.js +14 -0
  8. data/app/controllers/infotable/application_controller.rb +4 -0
  9. data/app/controllers/infotable/tables_controller.rb +299 -0
  10. data/app/helpers/infotable/application_helper.rb +5 -0
  11. data/app/helpers/infotable/tables_helper.rb +354 -0
  12. data/app/jobs/infotable/application_job.rb +4 -0
  13. data/app/mailers/infotable/application_mailer.rb +6 -0
  14. data/app/models/infotable/application_record.rb +5 -0
  15. data/app/views/infotable/tables/_column_visibility.html.erb +100 -0
  16. data/app/views/infotable/tables/_filter_drawer.html.erb +65 -0
  17. data/app/views/infotable/tables/_next_page_frame.html.erb +39 -0
  18. data/app/views/infotable/tables/_pagination.html.erb +101 -0
  19. data/app/views/infotable/tables/_side_panel.html.erb +63 -0
  20. data/app/views/infotable/tables/_table.html.erb +135 -0
  21. data/app/views/infotable/tables/_table_rows.html.erb +38 -0
  22. data/app/views/infotable/tables/filters/_boolean.html.erb +22 -0
  23. data/app/views/infotable/tables/filters/_date.html.erb +30 -0
  24. data/app/views/infotable/tables/filters/_number.html.erb +32 -0
  25. data/app/views/infotable/tables/filters/_select.html.erb +68 -0
  26. data/app/views/infotable/tables/filters/_text.html.erb +38 -0
  27. data/app/views/infotable/tables/show.html.erb +113 -0
  28. data/app/views/layouts/infotable/application.html.erb +15 -0
  29. data/config/importmap.rb +6 -0
  30. data/config/locales/en.yml +88 -0
  31. data/config/locales/pl.yml +88 -0
  32. data/config/routes.rb +6 -0
  33. data/lib/generators/infotable/install/install_generator.rb +565 -0
  34. data/lib/generators/infotable/install/templates/TAILWIND_CONFIG.md +131 -0
  35. data/lib/generators/infotable/install/templates/initializer.rb +26 -0
  36. data/lib/generators/infotable/install/templates/tailwind.config.js +284 -0
  37. data/lib/generators/infotable/table/table_generator.rb +95 -0
  38. data/lib/generators/infotable/table/templates/table.rb.tt +26 -0
  39. data/lib/infotable/base.rb +463 -0
  40. data/lib/infotable/batch_actions.rb +34 -0
  41. data/lib/infotable/configuration.rb +53 -0
  42. data/lib/infotable/engine.rb +51 -0
  43. data/lib/infotable/filter_processor.rb +326 -0
  44. data/lib/infotable/formatters/action_formatter.rb +141 -0
  45. data/lib/infotable/formatters/avatar_formatter.rb +49 -0
  46. data/lib/infotable/formatters/badge_formatter.rb +80 -0
  47. data/lib/infotable/formatters/base_formatter.rb +47 -0
  48. data/lib/infotable/formatters/boolean_formatter.rb +38 -0
  49. data/lib/infotable/formatters/currency_formatter.rb +22 -0
  50. data/lib/infotable/formatters/date_formatter.rb +58 -0
  51. data/lib/infotable/formatters/link_formatter.rb +48 -0
  52. data/lib/infotable/formatters/percent_formatter.rb +27 -0
  53. data/lib/infotable/search_processor.rb +42 -0
  54. data/lib/infotable/table_resolver.rb +45 -0
  55. data/lib/infotable/version.rb +3 -0
  56. data/lib/infotable.rb +54 -0
  57. data/lib/tasks/infotable.rake +55 -0
  58. metadata +216 -0
@@ -0,0 +1,14 @@
1
+ // InfoTable JavaScript entry point
2
+ // This works with any bundler: importmap, esbuild, webpack, vite, etc.
3
+
4
+ import TableController from "./controllers/table_controller";
5
+ import DrawerController from "./controllers/drawer_controller";
6
+
7
+ // Export controllers for manual registration
8
+ export { TableController, DrawerController };
9
+
10
+ // Auto-register function for convenience
11
+ export function registerInfotableControllers(application) {
12
+ application.register("infotable--table", TableController);
13
+ application.register("infotable--drawer", DrawerController);
14
+ }
@@ -0,0 +1,4 @@
1
+ module Infotable
2
+ class ApplicationController < Infotable.configuration.parent_controller.constantize
3
+ end
4
+ end
@@ -0,0 +1,299 @@
1
+ module Infotable
2
+ class TablesController < ApplicationController
3
+ include Pagy::Method
4
+
5
+ before_action :load_table_class
6
+ before_action :authorize_table
7
+
8
+ # A value PostgreSQL cannot represent (e.g. an id filter of 10**40) is a bad request, not a crash
9
+ rescue_from ActiveRecord::RangeError do
10
+ render plain: "Parameter out of range", status: :bad_request
11
+ end
12
+ before_action :set_params
13
+
14
+ def show
15
+ process_table_request
16
+ render_table_frame
17
+ end
18
+
19
+ def data
20
+ process_table_request
21
+
22
+ respond_to do |format|
23
+ format.html {
24
+ # Always render the full frame
25
+ render_table_frame
26
+ }
27
+ format.turbo_stream {
28
+ # For infinite scroll - return turbo stream to append rows and replace pagination frame
29
+ # Following the guide's "Frames + Streams" pattern
30
+ streams = [
31
+ turbo_stream.append(
32
+ "#{params[:table_name]}_tbody",
33
+ partial: "infotable/tables/table_rows",
34
+ locals: {
35
+ records: @records,
36
+ table_class: @table_class
37
+ }
38
+ )
39
+ ]
40
+
41
+ # Replace the pagination frame with the next one (or end indicator)
42
+ if @pagy.next
43
+ streams << turbo_stream.replace(
44
+ "#{params[:table_name]}_pagination",
45
+ partial: "infotable/tables/next_page_frame"
46
+ )
47
+ else
48
+ # No more pages - replace with end indicator
49
+ streams << turbo_stream.replace(
50
+ "#{params[:table_name]}_pagination",
51
+ '<div class="infotable-end-indicator border-t border-gray-100 bg-white px-6 py-8 text-center"><div class="inline-flex items-center gap-2 text-sm text-gray-500"><svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg><span class="font-medium">All items loaded</span></div></div>'.html_safe
52
+ )
53
+ end
54
+
55
+ render turbo_stream: streams
56
+ }
57
+ end
58
+ end
59
+
60
+ def export
61
+ require "csv"
62
+
63
+ # Process the same query as show/data but without pagination
64
+ @query = build_query
65
+
66
+ # Get visible columns from params or use all visible columns
67
+ visible_column_names = if params[:visible_columns].present?
68
+ params[:visible_columns].split(",").map(&:strip)
69
+ else
70
+ @table_class.visible_columns.map { |c| c[:name].to_s }
71
+ end
72
+ visible_columns = @table_class.columns.select { |col| visible_column_names.include?(col[:name].to_s) }
73
+
74
+ # Set CSV headers for streaming download
75
+ headers["Content-Type"] = "text/csv; charset=utf-8"
76
+ headers["Content-Disposition"] = "attachment; filename=\"#{params[:table_name].to_s.gsub(/[^\w-]/, '')}_#{Time.current.strftime('%Y%m%d_%H%M%S')}.csv\""
77
+ headers["Last-Modified"] = Time.current.httpdate
78
+ headers["Cache-Control"] = "no-cache"
79
+ headers["X-Accel-Buffering"] = "no" # Disable buffering for nginx
80
+ headers["X-Content-Type-Options"] = "nosniff"
81
+
82
+ # Stream CSV response
83
+ self.response_body = Enumerator.new do |y|
84
+ # Write header row
85
+ header_row = visible_columns.map { |col| col[:label] }
86
+ y << CSV.generate_line(header_row)
87
+
88
+ # Write data rows using find_each for memory efficiency
89
+ # Batched with OFFSET so the requested ORDER BY is kept (find_each ignores it)
90
+ offset = 0
91
+ loop do
92
+ batch = @query.limit(EXPORT_BATCH_SIZE).offset(offset).to_a
93
+ batch.each do |record|
94
+ row = visible_columns.map { |column| csv_safe(raw_cell_value(record, column)) }
95
+ y << CSV.generate_line(row)
96
+ end
97
+ break if batch.size < EXPORT_BATCH_SIZE
98
+ offset += EXPORT_BATCH_SIZE
99
+ end
100
+ end
101
+ end
102
+
103
+ private
104
+
105
+ def load_table_class
106
+ @table_class = Infotable::TableResolver.resolve(params[:table_name])
107
+ rescue Infotable::TableResolver::NotFound => e
108
+ render plain: e.message, status: :not_found
109
+ end
110
+
111
+ def authorize_table
112
+ head :forbidden unless @table_class.authorized?(self)
113
+ end
114
+
115
+ def set_params
116
+ @page = params[:page].to_s.to_i.clamp(1, MAX_PAGE)
117
+ @per_page = sanitize_per_page(scalar_param(:per_page))
118
+ @sorts = parse_sorts
119
+ @sort_column = @sorts.first&.first
120
+ @sort_direction = @sorts.first ? @sorts.first.last : normalize_sort_direction(scalar_param(:sort_direction))
121
+ @filters = params[:filters].respond_to?(:to_unsafe_h) ? params[:filters] : {}
122
+ @search = scalar_param(:search).to_s.strip.presence
123
+ @infinite_scroll_enabled = @table_class.infinite_scroll_enabled?
124
+ end
125
+
126
+ def process_table_request
127
+ @query = build_query
128
+
129
+ # Apply pagination
130
+ @pagy, @records = paginate(@page)
131
+ rescue Pagy::RangeError # page past the end: show the first page
132
+ @pagy, @records = paginate(1)
133
+ end
134
+
135
+ def paginate(page)
136
+ if @table_class.countless?
137
+ pagy(:countless, @query, limit: @per_page, page: page, raise_range_error: true)
138
+ else
139
+ pagy(:offset, @query, limit: @per_page, page: page, raise_range_error: true)
140
+ end
141
+ end
142
+
143
+ # Base query + filters + sorting, shared by show/data and export
144
+ def build_query
145
+ query = @table_class.base_query(self)
146
+
147
+ if @filters.present?
148
+ query = Infotable::FilterProcessor.new(query, @table_class, @filters).apply
149
+ end
150
+
151
+ if @search
152
+ query = Infotable::SearchProcessor.new(query, @table_class, @search).apply
153
+ end
154
+
155
+ query = apply_sorting(query)
156
+ @table_class.preloads.any? ? query.preload(*@table_class.preloads) : query
157
+ end
158
+
159
+ def apply_sorting(query)
160
+ sorts = @sorts.presence || default_sorts
161
+ return query if sorts.empty?
162
+
163
+ query = sorts.reduce(query) do |relation, (column, direction)|
164
+ relation.order(Arel.sql("#{get_sort_expression(column, relation)} #{direction}"))
165
+ end
166
+ add_sort_tiebreaker(query, sorts.map(&:first))
167
+ end
168
+
169
+ def default_sorts
170
+ column = @table_class.sort_settings[:column]
171
+ return [] unless column
172
+
173
+ [ [ column.to_s, normalize_sort_direction(@table_class.sort_settings[:direction]) ] ]
174
+ end
175
+
176
+ MAX_SORTS = 3
177
+
178
+ # Multi-column: sort=name:asc,age:desc (shift-click in the UI). Falls back to the
179
+ # single sort_column / sort_direction pair. Unknown or non-sortable columns are dropped.
180
+ def parse_sorts
181
+ pairs =
182
+ if (multi = scalar_param(:sort)).present?
183
+ multi.split(",").first(MAX_SORTS).map { |item| item.split(":", 2) }
184
+ elsif (single = scalar_param(:sort_column)).present?
185
+ [ [ single, scalar_param(:sort_direction) ] ]
186
+ else
187
+ []
188
+ end
189
+
190
+ pairs.filter_map do |name, direction|
191
+ [ name.to_s, normalize_sort_direction(direction) ] if column_sortable?(name)
192
+ end.uniq(&:first)
193
+ end
194
+
195
+ # Equal sort keys have no defined order in PostgreSQL, so rows can repeat or vanish
196
+ # between pages. Order by the primary key last to make pagination stable.
197
+ def add_sort_tiebreaker(query, sort_columns)
198
+ model = query.model
199
+ pk = model.primary_key
200
+ return query if pk.blank? || sort_columns.map(&:to_s).include?(pk) || query.distinct_value
201
+
202
+ query.order(Arel.sql("#{model.table_name}.#{pk} ASC"))
203
+ end
204
+
205
+ MAX_PER_PAGE = 500
206
+ MAX_PAGE = 1_000_000
207
+
208
+ # Query params are user input: arrays/hashes ("sort_column[]=x") are treated as absent
209
+ def scalar_param(key)
210
+ value = params[key]
211
+ value.is_a?(String) ? value : nil
212
+ end
213
+ EXPORT_BATCH_SIZE = 1000
214
+
215
+ def sanitize_per_page(value)
216
+ default = @table_class.pagination_settings[:per_page]
217
+ requested = value.to_i
218
+ requested.positive? ? [ requested, MAX_PER_PAGE ].min : default
219
+ end
220
+
221
+ # Guard against spreadsheet formula injection
222
+ def csv_safe(value)
223
+ value.is_a?(String) && value.match?(/\A[=+\-@\t\r]/) ? "'#{value}" : value
224
+ end
225
+
226
+ def render_table_frame
227
+ respond_to do |format|
228
+ format.html { render "infotable/tables/show" }
229
+ format.turbo_stream {
230
+ # For turbo-stream requests, render the show template
231
+ # Turbo will extract the frame content
232
+ render "infotable/tables/show"
233
+ }
234
+ end
235
+ end
236
+
237
+ # Normalize sort direction to always be a string ('asc' or 'desc')
238
+ def normalize_sort_direction(direction)
239
+ direction = direction.to_s.downcase
240
+ %w[asc desc].include?(direction) ? direction : "asc"
241
+ end
242
+
243
+ # Check if column is sortable
244
+ def column_sortable?(column_name)
245
+ @table_class.sortable_columns.any? { |col| col[:name].to_s == column_name.to_s }
246
+ end
247
+
248
+ # Sanitize column name to prevent SQL injection
249
+ def sanitize_column_name(column_name)
250
+ # Only allow alphanumeric characters, underscores, and dots (for associations)
251
+ column_name.to_s.gsub(/[^a-zA-Z0-9_.]/, "")
252
+ end
253
+
254
+ # Get the SQL expression for sorting a column
255
+ # For virtual columns, use virtual_sql if provided, otherwise use qualified column name
256
+ def get_sort_expression(column_name, query)
257
+ column = @table_class.columns.find { |col| col[:name].to_s == column_name.to_s }
258
+
259
+ if column && column[:sort_sql].present?
260
+ column[:sort_sql]
261
+ elsif column && column[:sql].present?
262
+ column[:sql]
263
+ elsif column && column[:virtual] && column[:virtual_sql].present?
264
+ # Virtual column with SQL expression - use it directly
265
+ column[:virtual_sql]
266
+ else
267
+ # Regular column - qualify with table name to avoid ambiguous column errors
268
+ table_name = query.model.table_name
269
+ sanitized_column = sanitize_column_name(column_name)
270
+ "#{table_name}.#{sanitized_column}"
271
+ end
272
+ end
273
+
274
+ # Extract raw cell value for CSV export (without formatting)
275
+ def raw_cell_value(record, column)
276
+ value = @table_class.cell_value(record, column)
277
+
278
+ # Return nil placeholder if value is nil
279
+ if value.nil?
280
+ return @table_class.nil_value_placeholder
281
+ end
282
+
283
+ # Return empty placeholder if value is empty string
284
+ if value.is_a?(String) && value.empty?
285
+ return @table_class.empty_value_placeholder
286
+ end
287
+
288
+ # Return raw value without formatting
289
+ case column[:type]
290
+ when :datetime, :date
291
+ value.iso8601
292
+ when :boolean
293
+ value ? "true" : "false"
294
+ else
295
+ value.to_s
296
+ end
297
+ end
298
+ end
299
+ end
@@ -0,0 +1,5 @@
1
+ module Infotable
2
+ module ApplicationHelper
3
+ include Infotable::TablesHelper
4
+ end
5
+ end
@@ -0,0 +1,354 @@
1
+ module Infotable
2
+ module TablesHelper
3
+ # Main helper method to render an infotable
4
+ # Usage: <%= infotable :users_table %>
5
+ def infotable(table_name, **options, &block)
6
+ table_class = resolve_table_class(table_name)
7
+
8
+ # Merge options with defaults
9
+ config = build_table_config(table_class, options)
10
+
11
+ # Allow block configuration
12
+ if block_given?
13
+ yield config if block.arity > 0
14
+ end
15
+
16
+ # Build locals for partial
17
+ locals = {
18
+ table_name: table_name,
19
+ table_class: table_class,
20
+ config: config
21
+ }
22
+
23
+ # Only pass title/subtitle if explicitly provided
24
+ locals[:title] = options[:title] if options.key?(:title)
25
+ locals[:subtitle] = options[:subtitle] if options.key?(:subtitle)
26
+
27
+ # Render the table frame
28
+ render partial: "infotable/tables/table", locals: locals
29
+ end
30
+
31
+ # Format cell value based on column definition
32
+ def format_cell_value(record, column, table_class = nil)
33
+ value = (table_class || Infotable::Base).cell_value(record, column)
34
+
35
+ # If formatter is provided, use it (formatters handle nil values themselves)
36
+ if column[:formatter]
37
+ if column[:formatter].is_a?(Proc)
38
+ # Execute formatter proc with value and record
39
+ return instance_exec(value, record, &column[:formatter])
40
+ elsif column[:formatter].is_a?(Symbol)
41
+ # Use named formatter
42
+ return placeholder_for(value, column, table_class) ||
43
+ format_with_named_formatter(value, record, column)
44
+ elsif column[:formatter].is_a?(Hash)
45
+ # Formatter with options: { type: :badge, options: {...} }
46
+ formatter_type = column[:formatter][:type]
47
+ formatter_options = column[:formatter][:options] || {}
48
+ return format_with_named_formatter(value, record, column.merge(formatter: formatter_type, formatter_options: formatter_options))
49
+ end
50
+ end
51
+
52
+ # Return nil placeholder for nil values (except for types that handle nil specially)
53
+ if value.nil? && table_class && ![ :boolean, :action ].include?(column[:type])
54
+ return table_class.nil_value_placeholder
55
+ end
56
+
57
+ # Return empty placeholder for empty strings (except for types that handle empty specially)
58
+ if value.is_a?(String) && value.empty? && table_class && ![ :boolean, :action ].include?(column[:type])
59
+ return table_class.empty_value_placeholder
60
+ end
61
+
62
+ # Default formatting based on type
63
+ case column[:type]
64
+ when :datetime
65
+ format_datetime(value)
66
+ when :date
67
+ format_date(value)
68
+ when :boolean
69
+ format_boolean(value, column)
70
+ when :currency
71
+ format_currency(value, column)
72
+ when :badge
73
+ format_badge(value, column)
74
+ when :action
75
+ format_actions(value, record, column)
76
+ else
77
+ value.to_s
78
+ end
79
+ end
80
+
81
+ # Build export params with current filters, sort, and visible columns
82
+ def build_export_params(table_name, table_class)
83
+ params_hash = {
84
+ table_name: table_name,
85
+ sort_column: scalar_request_param(:sort_column),
86
+ sort_direction: scalar_request_param(:sort_direction),
87
+ sort: scalar_request_param(:sort)
88
+ }
89
+
90
+ # Add filters if present
91
+ if params[:filters].present?
92
+ params_hash[:filters] = params[:filters]
93
+ end
94
+
95
+ # Get visible columns from localStorage via JavaScript or use defaults
96
+ # For now, we'll pass all visible columns and let the controller handle it
97
+ # The JavaScript will need to update the link dynamically
98
+ visible_column_names = table_class.visible_columns.map { |c| c[:name].to_s }
99
+ params_hash[:visible_columns] = visible_column_names.join(",")
100
+
101
+ params_hash.compact
102
+ end
103
+
104
+ private
105
+
106
+ def resolve_table_class(table_name)
107
+ Infotable::TableResolver.resolve(table_name)
108
+ end
109
+
110
+ def build_table_config(table_class, options)
111
+ ActiveSupport::OrderedOptions.new.merge!({
112
+ per_page: options[:per_page] || table_class.pagination_settings[:per_page],
113
+ per_page_options: options[:per_page_options] || table_class.pagination_settings[:per_page_options],
114
+ drawer_position: options[:drawer_position] || Infotable.configuration.drawer_position,
115
+ drawer_width: options[:drawer_width] || Infotable.configuration.drawer_width,
116
+ theme: options[:theme] || Infotable.configuration.theme,
117
+ autofilter: options.fetch(:autofilter, Infotable.configuration.autofilter)
118
+ })
119
+ end
120
+
121
+ # nil / "" placeholders for named formatters, which would otherwise each
122
+ # render their own idea of "nothing" (BooleanFormatter and ActionFormatter handle it themselves)
123
+ def placeholder_for(value, column, table_class)
124
+ return unless table_class
125
+ return if %i[boolean action actions avatar].include?(column[:type]) || %i[boolean action actions avatar].include?(column[:formatter])
126
+
127
+ return table_class.nil_value_placeholder if value.nil?
128
+ table_class.empty_value_placeholder if value.is_a?(String) && value.empty?
129
+ end
130
+
131
+ def format_with_named_formatter(value, record, column)
132
+ formatter_name = column[:formatter]
133
+ formatter_options = column[:formatter_options] || {}
134
+
135
+ formatter_class = case formatter_name
136
+ when :date, :datetime
137
+ Infotable::Formatters::DateFormatter
138
+ when :currency
139
+ Infotable::Formatters::CurrencyFormatter
140
+ when :boolean
141
+ Infotable::Formatters::BooleanFormatter
142
+ when :badge, :status
143
+ Infotable::Formatters::BadgeFormatter
144
+ when :action, :actions
145
+ Infotable::Formatters::ActionFormatter
146
+ when :link
147
+ Infotable::Formatters::LinkFormatter
148
+ when :avatar
149
+ Infotable::Formatters::AvatarFormatter
150
+ when :percent
151
+ Infotable::Formatters::PercentFormatter
152
+ else
153
+ # Custom formatter: :avatar => AvatarFormatter (or Namespaced::AvatarFormatter)
154
+ "#{formatter_name.to_s.camelize}Formatter".safe_constantize ||
155
+ raise(Infotable::Error, "Unknown formatter :#{formatter_name} for column :#{column[:name]}. " \
156
+ "Built-ins: date, currency, boolean, badge, action, link, avatar, percent; custom: define #{formatter_name.to_s.camelize}Formatter.")
157
+ end
158
+
159
+ formatter_class.new(self).format(value, record, formatter_options)
160
+ end
161
+
162
+ # Default formatters for types
163
+ def format_datetime(value)
164
+ return "" if value.blank?
165
+ formatter = Infotable::Formatters::DateFormatter.new(self)
166
+ formatter.format(value, nil, format: :datetime)
167
+ end
168
+
169
+ def format_date(value)
170
+ return "" if value.blank?
171
+ formatter = Infotable::Formatters::DateFormatter.new(self)
172
+ formatter.format(value, nil, format: :short)
173
+ end
174
+
175
+ def format_boolean(value, column)
176
+ formatter = Infotable::Formatters::BooleanFormatter.new(self)
177
+ formatter.format(value, nil, column[:formatter_options] || {})
178
+ end
179
+
180
+ def format_currency(value, column)
181
+ return "" if value.blank?
182
+ formatter = Infotable::Formatters::CurrencyFormatter.new(self)
183
+ formatter.format(value, nil, column[:formatter_options] || {})
184
+ end
185
+
186
+ def format_badge(value, column)
187
+ return "" if value.blank?
188
+ formatter = Infotable::Formatters::BadgeFormatter.new(self)
189
+ formatter.format(value, nil, column[:formatter_options] || {})
190
+ end
191
+
192
+ def format_actions(value, record, column)
193
+ formatter = Infotable::Formatters::ActionFormatter.new(self)
194
+ formatter.format(value, record, column[:formatter_options] || column[:actions] || {})
195
+ end
196
+
197
+ # Route helpers for engine routes
198
+ def infotable_table_path(options = {})
199
+ Infotable::Engine.routes.url_helpers.table_path(options)
200
+ end
201
+
202
+ def infotable_table_data_path(options = {})
203
+ Infotable::Engine.routes.url_helpers.table_data_path(options)
204
+ end
205
+
206
+ def infotable_table_export_path(options = {})
207
+ Infotable::Engine.routes.url_helpers.table_export_path(options)
208
+ end
209
+
210
+ INITIAL_STATE_KEYS = %w[search sort sort_column sort_direction page per_page filters].freeze
211
+
212
+ # Table state carried by the host page's own URL as `?users_table[search]=x&users_table[page]=2`
213
+ # (written by the table controller). Only known keys and plain values survive; the data
214
+ # endpoint validates them again when the frame loads.
215
+ def infotable_initial_state(table_name)
216
+ raw = params[table_name.to_s]
217
+ return {} unless raw.respond_to?(:to_unsafe_h)
218
+
219
+ raw.to_unsafe_h.slice(*INITIAL_STATE_KEYS).each_with_object({}) do |(key, value), state|
220
+ clean = sanitize_state_value(value)
221
+ state[key.to_sym] = clean unless clean.nil? || clean == ""
222
+ end
223
+ end
224
+
225
+ # Helper to build table URL params while preserving current state
226
+ # This ensures sorting, pagination, and other state is maintained across interactions
227
+ def table_url_params(new_params = {})
228
+ base_params = {
229
+ table_name: params[:table_name],
230
+ sort_column: scalar_request_param(:sort_column),
231
+ sort_direction: scalar_request_param(:sort_direction),
232
+ sort: scalar_request_param(:sort),
233
+ page: scalar_request_param(:page),
234
+ per_page: scalar_request_param(:per_page)
235
+ }
236
+ base_params[:search] = params[:search] if params[:search].is_a?(String) && params[:search].present?
237
+ base_params[:filters] = params[:filters].to_unsafe_h if params[:filters].respond_to?(:to_unsafe_h) && params[:filters].present?
238
+
239
+ base_params.compact.merge(new_params)
240
+ end
241
+
242
+ def sanitize_state_value(value, depth = 0)
243
+ case value
244
+ when String then value.delete("\u0000")[0, 500]
245
+ when Array then depth < 3 ? value.first(50).filter_map { sanitize_state_value(_1, depth + 1) }.select { _1.is_a?(String) } : nil
246
+ when Hash then depth < 3 ? value.first(50).to_h { |k, v| [ k.to_s[0, 100], sanitize_state_value(v, depth + 1) ] }.compact : nil
247
+ end
248
+ end
249
+
250
+ DRAWER_WIDTH_FORMAT = /\A\d+(\.\d+)?(px|rem|em|vw|%)\z/
251
+ DEFAULT_DRAWER_WIDTH = "26rem".freeze
252
+
253
+ # HTML attributes for the slide-in drawer (side and width from config)
254
+ def infotable_drawer_attributes(config)
255
+ side = config.drawer_position.to_s == "left" ? "left" : "right"
256
+ width = config.drawer_width.to_s.match?(DRAWER_WIDTH_FORMAT) ? config.drawer_width.to_s : DEFAULT_DRAWER_WIDTH
257
+ hidden = side == "left" ? "-100%" : "100%"
258
+
259
+ {
260
+ style: "#{side}: 0; width: #{width}; max-width: 90%; transform: translateX(#{hidden});",
261
+ class: "absolute top-0 bottom-0 bg-white #{side == 'left' ? 'border-r rounded-l-xl' : 'border-l rounded-r-xl'} border-indigo-200 shadow-2xl transition-all duration-300 ease-in-out z-50",
262
+ role: "dialog",
263
+ inert: true, # closed drawer is off-screen: keep it out of the tab order (JS toggles it)
264
+ aria: { label: I18n.t("infotable.drawer.label"), hidden: true },
265
+ data: { infotable__drawer_target: "drawer", drawer_side: side }
266
+ }
267
+ end
268
+
269
+ BATCH_BUTTON_COLORS = {
270
+ default: "border-gray-300 bg-white text-gray-700 hover:bg-gray-50",
271
+ primary: "border-indigo-600 bg-indigo-600 text-white hover:bg-indigo-700",
272
+ success: "border-green-600 bg-green-600 text-white hover:bg-green-700",
273
+ warning: "border-yellow-500 bg-yellow-500 text-white hover:bg-yellow-600",
274
+ danger: "border-red-600 bg-red-600 text-white hover:bg-red-700"
275
+ }.freeze
276
+
277
+ def infotable_batch_button_classes(color)
278
+ "inline-flex items-center px-3 py-1.5 border text-sm font-medium rounded-lg transition-colors #{BATCH_BUTTON_COLORS.fetch(color, BATCH_BUTTON_COLORS[:default])}"
279
+ end
280
+
281
+ # Query params are user input: only plain strings are echoed back into links
282
+ def scalar_request_param(key)
283
+ value = params[key]
284
+ value.is_a?(String) ? value : nil
285
+ end
286
+
287
+ # [[column, direction], ...] currently applied, for header indicators
288
+ def current_sorts
289
+ @sorts || []
290
+ end
291
+
292
+ # Page numbers to link to: first, last and two either side of the current page.
293
+ # (Pagy's #series is protected since Pagy 43, and gaps were never rendered anyway.)
294
+ def infotable_page_window(pagy, around: 2)
295
+ last = pagy.last.to_i
296
+ ([ 1, last ] + ((pagy.page - around)..(pagy.page + around)).to_a).select { |n| n.between?(1, last) }.uniq.sort
297
+ end
298
+
299
+ # DOM id for a filter control, scoped by table so two tables on one page
300
+ # with the same column names do not collide (labels, getElementById).
301
+ def infotable_filter_id(table_name, column, suffix)
302
+ "filter_#{table_name}_#{column[:name]}_#{suffix}"
303
+ end
304
+
305
+ # [options, total] for a select filter. The Proc runs in the view (so it can use helpers),
306
+ # the result is cut to the column's `filter_options_limit` (default
307
+ # Infotable.configuration.max_filter_options) and, with `filter_options_cache: 5.minutes`,
308
+ # cached per table and column. Do not cache options that depend on the current user.
309
+ def infotable_filter_options(table_class, column)
310
+ source = column[:filter_options]
311
+ options = if source.is_a?(Proc)
312
+ ttl = column[:filter_options_cache]
313
+ if ttl
314
+ key = [ "infotable", "filter_options", table_class&.name || table_class.object_id, column[:name].to_s ]
315
+ Rails.cache.fetch(key, expires_in: ttl) { instance_exec(&source).to_a }
316
+ else
317
+ instance_exec(&source).to_a
318
+ end
319
+ else
320
+ source.to_a
321
+ end
322
+
323
+ limit = column[:filter_options_limit] || Infotable.configuration.max_filter_options
324
+ [ limit ? options.first(limit) : options, options.size ]
325
+ end
326
+
327
+ # Per-page choices for the selector: the table's own options plus the current limit
328
+ def per_page_choices(table_class, current)
329
+ (table_class.pagination_settings[:per_page_options].to_a + [ current ]).compact.map(&:to_i).uniq.sort
330
+ end
331
+
332
+ # Determine which filter partial to render based on column type and filter_type
333
+ def filter_type_for_column(column)
334
+ # Explicit filter_type takes precedence
335
+ return column[:filter_type].to_s if column[:filter_type]
336
+
337
+ # Otherwise infer from column type
338
+ case column[:type]
339
+ when :string, :text
340
+ "text"
341
+ when :boolean
342
+ "boolean"
343
+ when :integer, :float, :decimal, :number
344
+ "number"
345
+ when :date, :datetime
346
+ "date"
347
+ when :select
348
+ "select"
349
+ else
350
+ "text" # default to text filter
351
+ end
352
+ end
353
+ end
354
+ end
@@ -0,0 +1,4 @@
1
+ module Infotable
2
+ class ApplicationJob < ActiveJob::Base
3
+ end
4
+ end
@@ -0,0 +1,6 @@
1
+ module Infotable
2
+ class ApplicationMailer < ActionMailer::Base
3
+ default from: "from@example.com"
4
+ layout "mailer"
5
+ end
6
+ end