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.
- checksums.yaml +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +1043 -0
- data/Rakefile +6 -0
- data/app/assets/javascripts/infotable/controllers/drawer_controller.js +1412 -0
- data/app/assets/javascripts/infotable/controllers/table_controller.js +921 -0
- data/app/assets/javascripts/infotable/index.js +14 -0
- data/app/controllers/infotable/application_controller.rb +4 -0
- data/app/controllers/infotable/tables_controller.rb +299 -0
- data/app/helpers/infotable/application_helper.rb +5 -0
- data/app/helpers/infotable/tables_helper.rb +354 -0
- data/app/jobs/infotable/application_job.rb +4 -0
- data/app/mailers/infotable/application_mailer.rb +6 -0
- data/app/models/infotable/application_record.rb +5 -0
- data/app/views/infotable/tables/_column_visibility.html.erb +100 -0
- data/app/views/infotable/tables/_filter_drawer.html.erb +65 -0
- data/app/views/infotable/tables/_next_page_frame.html.erb +39 -0
- data/app/views/infotable/tables/_pagination.html.erb +101 -0
- data/app/views/infotable/tables/_side_panel.html.erb +63 -0
- data/app/views/infotable/tables/_table.html.erb +135 -0
- data/app/views/infotable/tables/_table_rows.html.erb +38 -0
- data/app/views/infotable/tables/filters/_boolean.html.erb +22 -0
- data/app/views/infotable/tables/filters/_date.html.erb +30 -0
- data/app/views/infotable/tables/filters/_number.html.erb +32 -0
- data/app/views/infotable/tables/filters/_select.html.erb +68 -0
- data/app/views/infotable/tables/filters/_text.html.erb +38 -0
- data/app/views/infotable/tables/show.html.erb +113 -0
- data/app/views/layouts/infotable/application.html.erb +15 -0
- data/config/importmap.rb +6 -0
- data/config/locales/en.yml +88 -0
- data/config/locales/pl.yml +88 -0
- data/config/routes.rb +6 -0
- data/lib/generators/infotable/install/install_generator.rb +565 -0
- data/lib/generators/infotable/install/templates/TAILWIND_CONFIG.md +131 -0
- data/lib/generators/infotable/install/templates/initializer.rb +26 -0
- data/lib/generators/infotable/install/templates/tailwind.config.js +284 -0
- data/lib/generators/infotable/table/table_generator.rb +95 -0
- data/lib/generators/infotable/table/templates/table.rb.tt +26 -0
- data/lib/infotable/base.rb +463 -0
- data/lib/infotable/batch_actions.rb +34 -0
- data/lib/infotable/configuration.rb +53 -0
- data/lib/infotable/engine.rb +51 -0
- data/lib/infotable/filter_processor.rb +326 -0
- data/lib/infotable/formatters/action_formatter.rb +141 -0
- data/lib/infotable/formatters/avatar_formatter.rb +49 -0
- data/lib/infotable/formatters/badge_formatter.rb +80 -0
- data/lib/infotable/formatters/base_formatter.rb +47 -0
- data/lib/infotable/formatters/boolean_formatter.rb +38 -0
- data/lib/infotable/formatters/currency_formatter.rb +22 -0
- data/lib/infotable/formatters/date_formatter.rb +58 -0
- data/lib/infotable/formatters/link_formatter.rb +48 -0
- data/lib/infotable/formatters/percent_formatter.rb +27 -0
- data/lib/infotable/search_processor.rb +42 -0
- data/lib/infotable/table_resolver.rb +45 -0
- data/lib/infotable/version.rb +3 -0
- data/lib/infotable.rb +54 -0
- data/lib/tasks/infotable.rake +55 -0
- 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,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,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
|