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,326 @@
|
|
|
1
|
+
module Infotable
|
|
2
|
+
class FilterProcessor
|
|
3
|
+
attr_reader :query, :table_class, :filters
|
|
4
|
+
|
|
5
|
+
def initialize(query, table_class, filters = {})
|
|
6
|
+
@query = query
|
|
7
|
+
@table_class = table_class
|
|
8
|
+
@filters = normalize_filters(filters)
|
|
9
|
+
@table_name = query.model.table_name
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# Apply all filters to the query
|
|
13
|
+
def apply
|
|
14
|
+
return query if filters.blank?
|
|
15
|
+
|
|
16
|
+
filters.each do |column_name, filter_params|
|
|
17
|
+
next if filter_params.blank?
|
|
18
|
+
|
|
19
|
+
column = find_column(column_name)
|
|
20
|
+
next unless column && column[:filterable]
|
|
21
|
+
|
|
22
|
+
@query = apply_filter(column, filter_params)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
query
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
# Accept Hash / ActionController::Parameters, drop anything else
|
|
31
|
+
def normalize_filters(filters)
|
|
32
|
+
filters = filters.to_unsafe_h if filters.respond_to?(:to_unsafe_h)
|
|
33
|
+
filters.is_a?(Hash) ? filters.with_indifferent_access : {}
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Scalar-only string value (arrays/hashes from tampered params become nil)
|
|
37
|
+
def scalar(value)
|
|
38
|
+
return nil if value.is_a?(Array) || value.is_a?(Hash)
|
|
39
|
+
|
|
40
|
+
value.is_a?(String) ? value.delete("\u0000") : value # PostgreSQL rejects NUL bytes
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Aggregate (virtual_sql) columns filter with HAVING; row-level :sql columns with WHERE
|
|
44
|
+
def having?(column)
|
|
45
|
+
column[:virtual] && column[:sql].blank?
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def find_column(column_name)
|
|
49
|
+
table_class.columns.find { |col| col[:name].to_s == column_name.to_s }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Get qualified column reference for SQL queries
|
|
53
|
+
# Returns either the virtual SQL expression or the qualified table.column format
|
|
54
|
+
def qualified_column_ref(column)
|
|
55
|
+
if column[:sql].present?
|
|
56
|
+
column[:sql]
|
|
57
|
+
elsif column[:virtual] && column[:virtual_sql].present?
|
|
58
|
+
column[:virtual_sql]
|
|
59
|
+
else
|
|
60
|
+
"#{@table_name}.#{column[:name]}"
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def apply_filter(column, filter_params)
|
|
65
|
+
filter_type = determine_filter_type(column)
|
|
66
|
+
|
|
67
|
+
case filter_type
|
|
68
|
+
when :text
|
|
69
|
+
apply_text_filter(column, filter_params)
|
|
70
|
+
when :boolean
|
|
71
|
+
apply_boolean_filter(column, filter_params)
|
|
72
|
+
when :number
|
|
73
|
+
apply_number_filter(column, filter_params)
|
|
74
|
+
when :date
|
|
75
|
+
apply_date_filter(column, filter_params)
|
|
76
|
+
when :select
|
|
77
|
+
apply_select_filter(column, filter_params)
|
|
78
|
+
else
|
|
79
|
+
query
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def determine_filter_type(column)
|
|
84
|
+
# Explicit filter_type
|
|
85
|
+
return column[:filter_type].to_sym if column[:filter_type]
|
|
86
|
+
|
|
87
|
+
# Infer from column type
|
|
88
|
+
case column[:type]
|
|
89
|
+
when :string, :text
|
|
90
|
+
:text
|
|
91
|
+
when :boolean
|
|
92
|
+
:boolean
|
|
93
|
+
when :integer, :float, :decimal, :number
|
|
94
|
+
:number
|
|
95
|
+
when :date, :datetime
|
|
96
|
+
:date
|
|
97
|
+
when :select
|
|
98
|
+
:select
|
|
99
|
+
else
|
|
100
|
+
:text
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Text filter with operations
|
|
105
|
+
def apply_text_filter(column, params)
|
|
106
|
+
return query unless params.is_a?(Hash)
|
|
107
|
+
|
|
108
|
+
op = params[:op] || params["op"] || "contains"
|
|
109
|
+
value = scalar(params[:value] || params["value"])
|
|
110
|
+
column_name = column[:name]
|
|
111
|
+
is_virtual = having?(column)
|
|
112
|
+
# Use qualified column reference
|
|
113
|
+
column_ref = qualified_column_ref(column)
|
|
114
|
+
|
|
115
|
+
result = query
|
|
116
|
+
|
|
117
|
+
# Use having() for virtual columns, where() for regular columns
|
|
118
|
+
filter_method = is_virtual ? :having : :where
|
|
119
|
+
|
|
120
|
+
case op
|
|
121
|
+
when "contains"
|
|
122
|
+
result = result.public_send(filter_method, "#{column_ref} ILIKE ?", "%#{sanitize_like(value)}%") if value.present?
|
|
123
|
+
when "exact"
|
|
124
|
+
if is_virtual
|
|
125
|
+
result = result.having("LOWER(#{column_ref}) = LOWER(?)", value) if value.present?
|
|
126
|
+
else
|
|
127
|
+
result = result.where("LOWER(#{column_ref}) = LOWER(?)", value) if value.present?
|
|
128
|
+
end
|
|
129
|
+
when "not_contain"
|
|
130
|
+
result = result.public_send(filter_method, "NOT #{column_ref} ILIKE ?", "%#{sanitize_like(value)}%") if value.present?
|
|
131
|
+
when "starts_with"
|
|
132
|
+
result = result.public_send(filter_method, "#{column_ref} ILIKE ?", "#{sanitize_like(value)}%") if value.present?
|
|
133
|
+
when "ends_with"
|
|
134
|
+
result = result.public_send(filter_method, "#{column_ref} ILIKE ?", "%#{sanitize_like(value)}") if value.present?
|
|
135
|
+
when "is_empty"
|
|
136
|
+
result = result.public_send(filter_method, "#{column_ref} IS NULL OR #{column_ref} = ''")
|
|
137
|
+
when "is_not_empty"
|
|
138
|
+
if is_virtual
|
|
139
|
+
result = result.having("#{column_ref} IS NOT NULL AND #{column_ref} != ''")
|
|
140
|
+
else
|
|
141
|
+
result = result.where.not("#{column_ref} IS NULL OR #{column_ref} = ''")
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
result
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Boolean filter
|
|
149
|
+
def apply_boolean_filter(column, params)
|
|
150
|
+
return query unless params.is_a?(Hash)
|
|
151
|
+
|
|
152
|
+
value = scalar(params[:value] || params["value"])
|
|
153
|
+
column_name = column[:name]
|
|
154
|
+
is_virtual = having?(column)
|
|
155
|
+
# Use qualified column reference
|
|
156
|
+
column_ref = qualified_column_ref(column)
|
|
157
|
+
|
|
158
|
+
result = query
|
|
159
|
+
|
|
160
|
+
# Use having() for virtual columns, where() for regular columns
|
|
161
|
+
filter_method = is_virtual ? :having : :where
|
|
162
|
+
|
|
163
|
+
case value.to_s
|
|
164
|
+
when "true"
|
|
165
|
+
if is_virtual
|
|
166
|
+
result = result.having("#{column_ref} = ?", true)
|
|
167
|
+
else
|
|
168
|
+
result = result.where("#{column_ref} = ?", true)
|
|
169
|
+
end
|
|
170
|
+
when "false"
|
|
171
|
+
if is_virtual
|
|
172
|
+
result = result.having("#{column_ref} = ?", false)
|
|
173
|
+
else
|
|
174
|
+
result = result.where("#{column_ref} = ?", false)
|
|
175
|
+
end
|
|
176
|
+
when "is_empty"
|
|
177
|
+
if is_virtual
|
|
178
|
+
result = result.having("#{column_ref} IS NULL")
|
|
179
|
+
else
|
|
180
|
+
result = result.where("#{column_ref} IS NULL")
|
|
181
|
+
end
|
|
182
|
+
when "is_not_empty"
|
|
183
|
+
if is_virtual
|
|
184
|
+
result = result.having("#{column_ref} IS NOT NULL")
|
|
185
|
+
else
|
|
186
|
+
result = result.where.not("#{column_ref} IS NULL")
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
result
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
NUMBER_OPERATORS = { "eq" => "=", "neq" => "<>", "gt" => ">", "gte" => ">=", "lt" => "<", "lte" => "<=" }.freeze
|
|
194
|
+
|
|
195
|
+
# Number filter. Without :op it is a range (from/to, either may be blank).
|
|
196
|
+
# With :op (eq neq gt gte lt lte between is_empty is_not_empty) it compares :value.
|
|
197
|
+
def apply_number_filter(column, params)
|
|
198
|
+
return query unless params.is_a?(Hash)
|
|
199
|
+
|
|
200
|
+
column_ref = qualified_column_ref(column)
|
|
201
|
+
filter_method = having?(column) ? :having : :where
|
|
202
|
+
op = (params[:op] || params["op"]).to_s
|
|
203
|
+
|
|
204
|
+
case op
|
|
205
|
+
when "is_empty" then return query.public_send(filter_method, "#{column_ref} IS NULL")
|
|
206
|
+
when "is_not_empty" then return query.public_send(filter_method, "#{column_ref} IS NOT NULL")
|
|
207
|
+
when *NUMBER_OPERATORS.keys
|
|
208
|
+
value = numeric(scalar(params[:value] || params["value"]))
|
|
209
|
+
return value.nil? ? query : query.public_send(filter_method, "#{column_ref} #{NUMBER_OPERATORS[op]} CAST(? AS numeric)", value)
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
from = numeric(scalar(params[:from] || params["from"]))
|
|
213
|
+
to = numeric(scalar(params[:to] || params["to"]))
|
|
214
|
+
result = query
|
|
215
|
+
# CAST: a decimal bound against an integer column is otherwise rejected by PostgreSQL
|
|
216
|
+
result = result.public_send(filter_method, "#{column_ref} >= CAST(? AS numeric)", from) if from
|
|
217
|
+
result = result.public_send(filter_method, "#{column_ref} <= CAST(? AS numeric)", to) if to
|
|
218
|
+
result
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# Date filter. Without :op it is a range (from/to). With :op (on before after between
|
|
222
|
+
# is_empty is_not_empty) it compares :value; `on` matches the whole day.
|
|
223
|
+
def apply_date_filter(column, params)
|
|
224
|
+
return query unless params.is_a?(Hash)
|
|
225
|
+
|
|
226
|
+
column_ref = qualified_column_ref(column)
|
|
227
|
+
filter_method = having?(column) ? :having : :where
|
|
228
|
+
datetime = column[:type] == :datetime
|
|
229
|
+
op = (params[:op] || params["op"]).to_s
|
|
230
|
+
|
|
231
|
+
case op
|
|
232
|
+
when "is_empty" then return query.public_send(filter_method, "#{column_ref} IS NULL")
|
|
233
|
+
when "is_not_empty" then return query.public_send(filter_method, "#{column_ref} IS NOT NULL")
|
|
234
|
+
when "on", "before", "after"
|
|
235
|
+
day = parse_date(scalar(params[:value] || params["value"]))
|
|
236
|
+
return query unless day
|
|
237
|
+
|
|
238
|
+
return case op
|
|
239
|
+
when "on" then query.public_send(filter_method, "#{column_ref} >= ? AND #{column_ref} <= ?", datetime ? day.beginning_of_day : day, datetime ? day.end_of_day : day)
|
|
240
|
+
when "before" then query.public_send(filter_method, "#{column_ref} < ?", datetime ? day.beginning_of_day : day)
|
|
241
|
+
else query.public_send(filter_method, "#{column_ref} > ?", datetime ? day.end_of_day : day)
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
from = parse_date(scalar(params[:from] || params["from"]))
|
|
246
|
+
to = parse_date(scalar(params[:to] || params["to"]))
|
|
247
|
+
to = to.end_of_day if to && datetime
|
|
248
|
+
|
|
249
|
+
result = query
|
|
250
|
+
result = result.public_send(filter_method, "#{column_ref} >= ?", from) if from
|
|
251
|
+
result = result.public_send(filter_method, "#{column_ref} <= ?", to) if to
|
|
252
|
+
result
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
# Select filter: one value (params[:value]) or several (array, or params[:value] as array).
|
|
256
|
+
# Values are cast to the column type; values the type cannot hold never match.
|
|
257
|
+
def apply_select_filter(column, params)
|
|
258
|
+
raw = params.is_a?(Hash) ? (params[:value] || params["value"]) : params
|
|
259
|
+
values = Array(raw).flatten.reject { |v| v.is_a?(Hash) || v.blank? }.map { |v| v.to_s.delete("\u0000") }.reject(&:blank?)
|
|
260
|
+
return query if values.empty?
|
|
261
|
+
|
|
262
|
+
typed = values.map { |v| cast_select_value(column, v) }.reject(&:nil?)
|
|
263
|
+
return query.none if typed.empty?
|
|
264
|
+
|
|
265
|
+
ref = qualified_column_ref(column)
|
|
266
|
+
# Custom SQL of unknown type (declared :string but e.g. comments.author_id): compare as text
|
|
267
|
+
ref = "CAST(#{ref} AS text)" if (column[:sql].present? || column[:virtual]) && DECLARED_TYPES[column[:type]].nil?
|
|
268
|
+
|
|
269
|
+
query.public_send(having?(column) ? :having : :where, "#{ref} IN (?)", typed)
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
INTEGER_RANGE = (-(2**63)..(2**63 - 1))
|
|
273
|
+
|
|
274
|
+
def cast_select_value(column, value)
|
|
275
|
+
case select_column_type(column)
|
|
276
|
+
when :integer, :bigint
|
|
277
|
+
Integer(value, 10, exception: false)&.then { |n| n if INTEGER_RANGE.cover?(n) }
|
|
278
|
+
when :decimal, :float
|
|
279
|
+
numeric(value)
|
|
280
|
+
when :boolean
|
|
281
|
+
{ "true" => true, "false" => false, "1" => true, "0" => false }[value.downcase]
|
|
282
|
+
when :date, :datetime
|
|
283
|
+
parse_date(value)
|
|
284
|
+
else
|
|
285
|
+
value
|
|
286
|
+
end
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
DECLARED_TYPES = { integer: :integer, number: :decimal, decimal: :decimal, float: :decimal, currency: :decimal,
|
|
290
|
+
boolean: :boolean, date: :date, datetime: :datetime }.freeze
|
|
291
|
+
|
|
292
|
+
# Attribute type of the model column; for custom SQL / aggregate columns the type declared in the DSL
|
|
293
|
+
def select_column_type(column)
|
|
294
|
+
unless column[:sql].present? || column[:virtual]
|
|
295
|
+
db_type = query.model.columns_hash[column[:name].to_s]&.type
|
|
296
|
+
return db_type if db_type
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
DECLARED_TYPES[column[:type]]
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
# Helper to sanitize LIKE values
|
|
303
|
+
def sanitize_like(value)
|
|
304
|
+
return "" if value.blank?
|
|
305
|
+
value.to_s.gsub(/[%_]/, '\\\\\&')
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
def numeric(value)
|
|
309
|
+
number = BigDecimal(value.to_s)
|
|
310
|
+
return nil unless number.finite?
|
|
311
|
+
|
|
312
|
+
number.frac.zero? ? number.to_i : number
|
|
313
|
+
rescue ArgumentError, TypeError
|
|
314
|
+
nil
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
# Helper to parse date strings
|
|
318
|
+
def parse_date(date_string)
|
|
319
|
+
return nil if date_string.blank?
|
|
320
|
+
|
|
321
|
+
Date.parse(date_string.to_s)
|
|
322
|
+
rescue ArgumentError, RangeError
|
|
323
|
+
nil
|
|
324
|
+
end
|
|
325
|
+
end
|
|
326
|
+
end
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
module Infotable
|
|
2
|
+
module Formatters
|
|
3
|
+
class ActionFormatter < BaseFormatter
|
|
4
|
+
def format(value, record, options = {})
|
|
5
|
+
actions = options[:actions] || []
|
|
6
|
+
style = options[:style] || :buttons # :buttons, :links, :dropdown
|
|
7
|
+
|
|
8
|
+
case style
|
|
9
|
+
when :buttons
|
|
10
|
+
render_buttons(actions, record)
|
|
11
|
+
when :dropdown
|
|
12
|
+
render_dropdown(actions, record)
|
|
13
|
+
else
|
|
14
|
+
render_links(actions, record)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
def render_buttons(actions, record)
|
|
21
|
+
buttons = actions.map do |action|
|
|
22
|
+
render_button(action, record)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
safe_join(buttons, " ")
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def render_links(actions, record)
|
|
29
|
+
links = actions.map do |action|
|
|
30
|
+
render_link(action, record)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
safe_join(links, " • ")
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def render_button(action, record)
|
|
37
|
+
url = resolve_url(action[:url], record)
|
|
38
|
+
method = action[:method] || :get
|
|
39
|
+
data = action[:data] || {}
|
|
40
|
+
|
|
41
|
+
link_to(
|
|
42
|
+
button_content(action),
|
|
43
|
+
url,
|
|
44
|
+
class: button_classes(action[:color]),
|
|
45
|
+
data: link_data(data, method, action[:confirm])
|
|
46
|
+
)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def render_link(action, record)
|
|
50
|
+
url = resolve_url(action[:url], record)
|
|
51
|
+
method = action[:method] || :get
|
|
52
|
+
data = action[:data] || {}
|
|
53
|
+
|
|
54
|
+
link_to(
|
|
55
|
+
action[:label] || action[:icon] || view_context.t("infotable.formatters.action"),
|
|
56
|
+
url,
|
|
57
|
+
class: link_classes(action[:color]),
|
|
58
|
+
data: link_data(data, method, action[:confirm])
|
|
59
|
+
)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Turbo-compatible: non-GET verbs use data-turbo-method, confirm uses data-turbo-confirm
|
|
63
|
+
def link_data(data, method, confirm)
|
|
64
|
+
data = data.dup
|
|
65
|
+
data[:turbo_method] = method unless method.to_sym == :get
|
|
66
|
+
data[:turbo_confirm] = confirm if confirm
|
|
67
|
+
data
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def render_menu_item(action, record)
|
|
71
|
+
link_to(
|
|
72
|
+
action[:label] || action[:icon] || view_context.t("infotable.formatters.action"),
|
|
73
|
+
resolve_url(action[:url], record),
|
|
74
|
+
role: "menuitem",
|
|
75
|
+
class: "block px-3 py-1.5 text-sm whitespace-nowrap #{link_classes(action[:color])} hover:bg-gray-50",
|
|
76
|
+
data: link_data(action[:data] || {}, action[:method] || :get, action[:confirm])
|
|
77
|
+
)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def render_dropdown(actions, record)
|
|
81
|
+
# <details> gives open/close and keyboard support without JS
|
|
82
|
+
content_tag(:details, class: "relative inline-block text-left") do
|
|
83
|
+
summary = content_tag(:summary, "\u22EF".html_safe,
|
|
84
|
+
class: "list-none cursor-pointer inline-flex items-center px-2 py-1 rounded-md border border-gray-300 bg-white text-gray-600 hover:bg-gray-50",
|
|
85
|
+
"aria-label": view_context.t("infotable.formatters.action"), "aria-haspopup": "menu")
|
|
86
|
+
menu = content_tag(:div, role: "menu",
|
|
87
|
+
class: "absolute right-0 z-30 mt-1 min-w-36 rounded-md border border-gray-200 bg-white py-1 shadow-lg") do
|
|
88
|
+
safe_join(actions.map { |action| render_menu_item(action, record) })
|
|
89
|
+
end
|
|
90
|
+
summary + menu
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def button_content(action)
|
|
95
|
+
if action[:icon]
|
|
96
|
+
safe_join([ html_safe(action[:icon]), (action[:label] ? " #{action[:label]}" : nil) ].compact)
|
|
97
|
+
else
|
|
98
|
+
action[:label] || view_context.t("infotable.formatters.action")
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def button_classes(color = nil)
|
|
103
|
+
base = "inline-flex items-center px-2 py-1 border text-xs font-medium rounded-md transition-colors"
|
|
104
|
+
|
|
105
|
+
color_classes = case color&.to_s
|
|
106
|
+
when "primary"
|
|
107
|
+
"border-indigo-600 bg-indigo-600 text-white hover:bg-indigo-700"
|
|
108
|
+
when "danger"
|
|
109
|
+
"border-red-600 bg-red-600 text-white hover:bg-red-700"
|
|
110
|
+
when "success"
|
|
111
|
+
"border-green-600 bg-green-600 text-white hover:bg-green-700"
|
|
112
|
+
else
|
|
113
|
+
"border-gray-300 bg-white text-gray-700 hover:bg-gray-50"
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
"#{base} #{color_classes}"
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def link_classes(color = nil)
|
|
120
|
+
case color&.to_s
|
|
121
|
+
when "danger"
|
|
122
|
+
"text-red-600 hover:text-red-900"
|
|
123
|
+
when "success"
|
|
124
|
+
"text-green-600 hover:text-green-900"
|
|
125
|
+
else
|
|
126
|
+
"text-indigo-600 hover:text-indigo-900"
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def resolve_url(url, record)
|
|
131
|
+
if url.is_a?(Proc)
|
|
132
|
+
view_context.instance_exec(record, &url)
|
|
133
|
+
elsif url.is_a?(Symbol)
|
|
134
|
+
view_context.send(url, record)
|
|
135
|
+
else
|
|
136
|
+
url
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
module Infotable
|
|
2
|
+
module Formatters
|
|
3
|
+
# formatter: :avatar
|
|
4
|
+
# The cell value is the image URL. With no image, initials of `name` are shown on a coloured disc.
|
|
5
|
+
# formatter_options: {
|
|
6
|
+
# name: ->(record) { record.name }, # shown next to the picture and used for initials / alt text
|
|
7
|
+
# size: :md, # :sm, :md, :lg
|
|
8
|
+
# label: false # true (default when name is given) shows the name beside the picture
|
|
9
|
+
# }
|
|
10
|
+
class AvatarFormatter < BaseFormatter
|
|
11
|
+
SIZES = { sm: "h-6 w-6 text-xs", md: "h-8 w-8 text-sm", lg: "h-10 w-10 text-base" }.freeze
|
|
12
|
+
COLORS = [
|
|
13
|
+
"bg-indigo-100 text-indigo-700", "bg-green-100 text-green-700", "bg-yellow-100 text-yellow-700",
|
|
14
|
+
"bg-pink-100 text-pink-700", "bg-blue-100 text-blue-700", "bg-purple-100 text-purple-700"
|
|
15
|
+
].freeze
|
|
16
|
+
|
|
17
|
+
def format(value, record, options = {})
|
|
18
|
+
name = resolve(options[:name], record)
|
|
19
|
+
return "" if value.blank? && name.blank?
|
|
20
|
+
|
|
21
|
+
size = SIZES.fetch((options[:size] || :md).to_sym, SIZES[:md])
|
|
22
|
+
picture = image?(value) ? image(value, name, size) : initials(name, size)
|
|
23
|
+
return picture if name.blank? || options[:label] == false
|
|
24
|
+
|
|
25
|
+
content_tag(:div, safe_join([ picture, content_tag(:span, name.to_s, class: "truncate") ]), class: "flex items-center gap-2")
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def image?(value)
|
|
31
|
+
Infotable::Formatters::LinkFormatter.safe_url?(value) && value.to_s.match?(%r{\A(https?:)?//|\A/})
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def image(url, name, size)
|
|
35
|
+
tag.img(src: url.to_s, alt: name.to_s, loading: "lazy", class: "#{size.split.first(2).join(' ')} rounded-full object-cover flex-none")
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def initials(name, size)
|
|
39
|
+
letters = name.to_s.split.first(2).map { _1[0] }.join.upcase.presence || "?"
|
|
40
|
+
color = COLORS[name.to_s.sum % COLORS.size]
|
|
41
|
+
content_tag(:span, letters, class: "inline-flex items-center justify-center rounded-full font-medium flex-none #{size} #{color}", "aria-hidden": "true")
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def resolve(option, record)
|
|
45
|
+
option.is_a?(Proc) ? view_context.instance_exec(record, &option) : option
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
module Infotable
|
|
2
|
+
module Formatters
|
|
3
|
+
class BadgeFormatter < BaseFormatter
|
|
4
|
+
# Default color mappings
|
|
5
|
+
DEFAULT_COLORS = {
|
|
6
|
+
active: "green",
|
|
7
|
+
inactive: "gray",
|
|
8
|
+
pending: "yellow",
|
|
9
|
+
success: "green",
|
|
10
|
+
warning: "yellow",
|
|
11
|
+
error: "red",
|
|
12
|
+
info: "blue",
|
|
13
|
+
primary: "indigo"
|
|
14
|
+
}.freeze
|
|
15
|
+
|
|
16
|
+
def format(value, record, options = {})
|
|
17
|
+
return "" if value.blank?
|
|
18
|
+
|
|
19
|
+
color = determine_color(value, options)
|
|
20
|
+
size = options[:size] || :medium
|
|
21
|
+
display_value = options[:label] || value.to_s.titleize
|
|
22
|
+
|
|
23
|
+
render_badge(display_value, color, size)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def determine_color(value, options)
|
|
29
|
+
# Check if custom color mapping provided
|
|
30
|
+
if options[:colors].is_a?(Hash)
|
|
31
|
+
return options[:colors][value.to_s.downcase.to_sym] || options[:colors][value.to_s] || "gray"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Use default mapping
|
|
35
|
+
DEFAULT_COLORS[value.to_s.downcase.to_sym] || "gray"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def render_badge(text, color, size)
|
|
39
|
+
classes = badge_classes(color, size)
|
|
40
|
+
content_tag(:span, text, class: classes)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def badge_classes(color, size)
|
|
44
|
+
base = "inline-flex items-center rounded-full font-medium"
|
|
45
|
+
|
|
46
|
+
size_classes = case size
|
|
47
|
+
when :small
|
|
48
|
+
"px-2 py-0.5 text-xs"
|
|
49
|
+
when :large
|
|
50
|
+
"px-3 py-1 text-sm"
|
|
51
|
+
else # medium
|
|
52
|
+
"px-2.5 py-0.5 text-xs"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
color_classes = case color.to_s
|
|
56
|
+
when "green"
|
|
57
|
+
"bg-green-100 text-green-800"
|
|
58
|
+
when "red"
|
|
59
|
+
"bg-red-100 text-red-800"
|
|
60
|
+
when "yellow"
|
|
61
|
+
"bg-yellow-100 text-yellow-800"
|
|
62
|
+
when "blue"
|
|
63
|
+
"bg-blue-100 text-blue-800"
|
|
64
|
+
when "indigo"
|
|
65
|
+
"bg-indigo-100 text-indigo-800"
|
|
66
|
+
when "purple"
|
|
67
|
+
"bg-purple-100 text-purple-800"
|
|
68
|
+
when "pink"
|
|
69
|
+
"bg-pink-100 text-pink-800"
|
|
70
|
+
when "cyan"
|
|
71
|
+
"bg-cyan-100 text-cyan-800"
|
|
72
|
+
else # gray
|
|
73
|
+
"bg-gray-100 text-gray-800"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
"#{base} #{size_classes} #{color_classes}"
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
module Infotable
|
|
2
|
+
module Formatters
|
|
3
|
+
class BaseFormatter
|
|
4
|
+
attr_reader :view_context
|
|
5
|
+
|
|
6
|
+
def initialize(view_context)
|
|
7
|
+
@view_context = view_context
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
# Override this in subclasses
|
|
11
|
+
def format(value, record, options = {})
|
|
12
|
+
value.to_s
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
protected
|
|
16
|
+
|
|
17
|
+
# Helper methods available to all formatters
|
|
18
|
+
def link_to(*args, &block)
|
|
19
|
+
view_context.link_to(*args, &block)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def content_tag(*args, &block)
|
|
23
|
+
view_context.content_tag(*args, &block)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def safe_join(*args)
|
|
27
|
+
view_context.safe_join(*args)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def tag
|
|
31
|
+
view_context.tag
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def number_to_currency(*args)
|
|
35
|
+
view_context.number_to_currency(*args)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def number_with_delimiter(*args)
|
|
39
|
+
view_context.number_with_delimiter(*args)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def html_safe(str)
|
|
43
|
+
str.to_s.html_safe
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
module Infotable
|
|
2
|
+
module Formatters
|
|
3
|
+
class BooleanFormatter < BaseFormatter
|
|
4
|
+
def format(value, record, options = {})
|
|
5
|
+
true_text = options[:true_text] || view_context.t("infotable.formatters.boolean.true")
|
|
6
|
+
false_text = options[:false_text] || view_context.t("infotable.formatters.boolean.false")
|
|
7
|
+
style = options[:style] || :badge # :badge, :text, :icon
|
|
8
|
+
|
|
9
|
+
case style
|
|
10
|
+
when :badge
|
|
11
|
+
format_as_badge(value, true_text, false_text)
|
|
12
|
+
when :icon
|
|
13
|
+
format_as_icon(value)
|
|
14
|
+
else
|
|
15
|
+
value ? true_text : false_text
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def format_as_badge(value, true_text, false_text)
|
|
22
|
+
if value
|
|
23
|
+
content_tag(:span, true_text, class: "inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800")
|
|
24
|
+
else
|
|
25
|
+
content_tag(:span, false_text, class: "inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800")
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def format_as_icon(value)
|
|
30
|
+
if value
|
|
31
|
+
html_safe('<svg class="w-5 h-5 text-green-500" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/></svg>')
|
|
32
|
+
else
|
|
33
|
+
html_safe('<svg class="w-5 h-5 text-gray-400" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"/></svg>')
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|