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