infotable 1.4.1 → 1.6.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 (54) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +179 -11
  3. data/app/assets/javascripts/infotable/controllers/drawer_controller.js +74 -378
  4. data/app/assets/javascripts/infotable/controllers/table_controller.js +112 -120
  5. data/app/assets/javascripts/infotable/vendor/sortable.esm.js +3363 -0
  6. data/app/controllers/infotable/tables_controller.rb +16 -5
  7. data/app/helpers/infotable/tables_helper.rb +10 -48
  8. data/app/views/infotable/tables/_column_visibility.html.erb +4 -4
  9. data/app/views/infotable/tables/_end_indicator.html.erb +8 -0
  10. data/app/views/infotable/tables/_filter_drawer.html.erb +6 -6
  11. data/app/views/infotable/tables/_next_page_frame.html.erb +1 -9
  12. data/app/views/infotable/tables/_pagination.html.erb +4 -7
  13. data/app/views/infotable/tables/_table.html.erb +1 -1
  14. data/app/views/infotable/tables/_table_rows.html.erb +9 -2
  15. data/app/views/infotable/tables/filters/_boolean.html.erb +1 -1
  16. data/app/views/infotable/tables/filters/_range.html.erb +1 -1
  17. data/app/views/infotable/tables/filters/_select.html.erb +2 -2
  18. data/app/views/infotable/tables/filters/_text.html.erb +2 -2
  19. data/app/views/infotable/tables/show.html.erb +2 -2
  20. data/config/importmap.rb +2 -2
  21. data/config/locales/de.yml +126 -0
  22. data/config/locales/el.yml +126 -0
  23. data/config/locales/en.yml +18 -4
  24. data/config/locales/es.yml +126 -0
  25. data/config/locales/fr.yml +126 -0
  26. data/config/locales/it.yml +126 -0
  27. data/config/locales/pl.yml +18 -4
  28. data/exe/infotable-mcp +13 -0
  29. data/lib/generators/infotable/install/templates/initializer.rb +5 -3
  30. data/lib/generators/infotable/table/table_generator.rb +23 -1
  31. data/lib/generators/infotable/table/templates/table.rb.tt +8 -1
  32. data/lib/infotable/base.rb +62 -4
  33. data/lib/infotable/configuration.rb +1 -1
  34. data/lib/infotable/engine.rb +1 -8
  35. data/lib/infotable/filter_processor.rb +10 -1
  36. data/lib/infotable/formatters/boolean_formatter.rb +4 -4
  37. data/lib/infotable/formatters/currency_formatter.rb +4 -12
  38. data/lib/infotable/formatters/date_formatter.rb +3 -9
  39. data/lib/infotable/formatters/duration_formatter.rb +51 -0
  40. data/lib/infotable/formatters/filesize_formatter.rb +14 -0
  41. data/lib/infotable/formatters/image_formatter.rb +31 -0
  42. data/lib/infotable/formatters/number_formatter.rb +46 -0
  43. data/lib/infotable/formatters/tags_formatter.rb +44 -0
  44. data/lib/infotable/formatters/truncate_formatter.rb +19 -0
  45. data/lib/infotable/mcp/introspection.rb +94 -0
  46. data/lib/infotable/mcp/server.rb +26 -0
  47. data/lib/infotable/mcp/table_checker.rb +80 -0
  48. data/lib/infotable/mcp/table_writer.rb +66 -0
  49. data/lib/infotable/mcp/tools.rb +122 -0
  50. data/lib/infotable/mcp.rb +30 -0
  51. data/lib/infotable/table_resolver.rb +9 -0
  52. data/lib/infotable/version.rb +1 -1
  53. data/lib/infotable.rb +22 -0
  54. metadata +53 -4
@@ -1,7 +1,12 @@
1
1
  module Infotable
2
2
  class Base
3
+ # Options column accepts; anything else is logged as a probable typo
4
+ COLUMN_OPTIONS = %i[label type sortable searchable filterable visible exportable pinned virtual virtual_sql
5
+ sql sort_sql value includes formatter formatter_options actions filter_type filter_options
6
+ filter_options_cache filter_options_limit filter_multi_select width].freeze
7
+
3
8
  class << self
4
- attr_accessor :table_columns, :table_query, :table_query_sql, :pagination_settings, :sort_settings, :height_settings, :infinite_scroll_settings, :row_link_settings, :slim_rows_settings, :row_size_settings, :filters_button_settings, :display_settings, :nil_value_settings, :empty_value_settings, :table_authorize, :checkbox_settings, :batch_action_list, :countless_setting, :url_state_setting, :resizable_columns_setting
9
+ attr_accessor :table_columns, :table_query, :table_query_sql, :pagination_settings, :sort_settings, :height_settings, :infinite_scroll_settings, :row_link_settings, :row_attr_settings, :slim_rows_settings, :row_size_settings, :filters_button_settings, :display_settings, :nil_value_settings, :empty_value_settings, :table_authorize, :checkbox_settings, :batch_action_list, :countless_setting, :url_state_setting, :resizable_columns_setting
5
10
 
6
11
  def inherited(subclass)
7
12
  super
@@ -19,6 +24,7 @@ module Infotable
19
24
  subclass.infinite_scroll_settings = {
20
25
  enabled: false
21
26
  }
27
+ subclass.row_attr_settings = { class: nil, data: nil }
22
28
  subclass.row_link_settings = {
23
29
  enabled: false,
24
30
  url: nil
@@ -65,7 +71,9 @@ module Infotable
65
71
  subclass.countless_setting = parent.countless_setting
66
72
  subclass.url_state_setting = parent.url_state_setting
67
73
  subclass.resizable_columns_setting = parent.resizable_columns_setting
68
- %i[pagination_settings sort_settings height_settings infinite_scroll_settings row_link_settings
74
+ subclass.instance_variable_set(:@toolbar_setting, parent.instance_variable_get(:@toolbar_setting))
75
+ subclass.instance_variable_set(:@export_column_names, parent.instance_variable_get(:@export_column_names)&.dup)
76
+ %i[pagination_settings sort_settings height_settings infinite_scroll_settings row_link_settings row_attr_settings
69
77
  slim_rows_settings row_size_settings filters_button_settings display_settings
70
78
  nil_value_settings empty_value_settings].each do |attr|
71
79
  subclass.public_send("#{attr}=", parent.public_send(attr).dup)
@@ -134,8 +142,9 @@ module Infotable
134
142
  # Plucks when the id method is a plain column of an ungrouped query, loads records otherwise.
135
143
  def checkbox_ids(query, limit = nil)
136
144
  method = @checkbox_settings[:id_method]
137
- scope = query.reorder(nil)
138
- scope = scope.limit(limit) if limit
145
+ # Without a cap the order is irrelevant (and costs a sort); with one, the table's own order
146
+ # decides which rows make it in, so a capped selection is the first N rows the user sees
147
+ scope = limit ? query.limit(limit) : query.reorder(nil)
139
148
  model = query.model
140
149
 
141
150
  if model.column_names.include?(method.to_s) && scope.group_values.empty? && scope.having_clause.empty?
@@ -169,6 +178,10 @@ module Infotable
169
178
  # Define a column
170
179
  def column(name, **options)
171
180
  @table_columns ||= []
181
+ unknown = options.keys - COLUMN_OPTIONS
182
+ if unknown.any? && defined?(Rails) && Rails.logger
183
+ Rails.logger.warn("[Infotable] #{self.name || 'table'}: column :#{name} ignores unknown option(s) #{unknown.map(&:inspect).join(', ')}")
184
+ end
172
185
  type = options[:type] || :string
173
186
 
174
187
  # Determine width - use custom if provided and not blank, otherwise use default
@@ -319,6 +332,51 @@ module Infotable
319
332
  end
320
333
  end
321
334
 
335
+ # Extra CSS classes per row: a String, or a Proc that receives the record (nil/false adds nothing).
336
+ # Tailwind only generates classes it can see in your source, so write them out in full.
337
+ # @example
338
+ # row_class ->(order) { "bg-red-50" if order.overdue? }
339
+ def row_class(value = nil, &block)
340
+ @row_attr_settings[:class] = value || block
341
+ end
342
+
343
+ def row_classes_for(record)
344
+ setting = @row_attr_settings[:class]
345
+ (setting.respond_to?(:call) ? setting.call(record) : setting).presence
346
+ end
347
+
348
+ # Extra data-* attributes per row: a Proc that receives the record and returns a Hash
349
+ # ({ post_id: 1 } becomes data-post-id="1"). Handy for your own Stimulus controllers.
350
+ # @example
351
+ # row_data ->(post) { { post_id: post.id, status: post.status } }
352
+ def row_data(value = nil, &block)
353
+ @row_attr_settings[:data] = value || block
354
+ end
355
+
356
+ def row_data_for(record)
357
+ setting = @row_attr_settings[:data]
358
+ data = setting.respond_to?(:call) ? setting.call(record) : setting
359
+ data.is_a?(Hash) ? data : {}
360
+ end
361
+
362
+ # Clicking a row (not a link, button or input inside it) fires a cancelable
363
+ # `infotable:row-click` event on the row (detail: { id, url, data }). If nothing calls
364
+ # preventDefault() and the table has a row_link, the click falls back to following it.
365
+ # @example
366
+ # row_click true
367
+ def row_click(enabled = true)
368
+ @row_link_settings[:click] = enabled ? true : false
369
+ end
370
+
371
+ def row_click?
372
+ @row_link_settings[:click] ? true : false
373
+ end
374
+
375
+ # Rows react to clicks (row_link and/or row_click)
376
+ def row_clickable?
377
+ row_link_enabled? || row_click?
378
+ end
379
+
322
380
  # Set row size (padding and text size)
323
381
  # @param size [Symbol] - Row size (:slim, :mid, :default)
324
382
  # @example
@@ -31,7 +31,7 @@ module Infotable
31
31
  @nil_value = "-" # Default placeholder for nil values
32
32
  @empty_value = "-" # Default placeholder for empty strings
33
33
  @url_state = true # mirror table state in the address bar
34
- @max_select_all = 10_000 # "select all matching" collects at most this many ids (nil = no limit)
34
+ @max_select_all = 4000 # "select all matching" collects at most this many ids (nil = no limit). Rack accepts 4096 params per request, and every id is sent as ids[]
35
35
  @max_filter_options = 500 # select filters render at most this many <option>s (nil = no limit)
36
36
  @parent_controller = "ActionController::Base" # Set to "ApplicationController" to inherit host auth/filters
37
37
  end
@@ -6,18 +6,11 @@ module Infotable
6
6
  class Engine < ::Rails::Engine
7
7
  isolate_namespace Infotable
8
8
 
9
- # Configure engine
10
- config.infotable = ActiveSupport::OrderedOptions.new
11
-
12
- # Default configuration values
13
- config.infotable.table_min_height = "400px"
14
- config.infotable.table_max_height = "600px"
15
-
16
9
  # Rails already adds the engine's app/assets/* directories to the asset path;
17
10
  # only the precompile list is needed. Skipped for apps without an asset pipeline.
18
11
  initializer "infotable.assets" do |app|
19
12
  if app.config.respond_to?(:assets)
20
- app.config.assets.precompile += %w[infotable/controllers/*.js]
13
+ app.config.assets.precompile += %w[infotable/controllers/*.js infotable/vendor/*.js]
21
14
  end
22
15
  end
23
16
 
@@ -110,7 +110,7 @@ module Infotable
110
110
  column_name = column[:name]
111
111
  is_virtual = having?(column)
112
112
  # Use qualified column reference
113
- column_ref = qualified_column_ref(column)
113
+ column_ref = text_ref(column)
114
114
 
115
115
  result = query
116
116
 
@@ -145,6 +145,14 @@ module Infotable
145
145
  result
146
146
  end
147
147
 
148
+ # PostgreSQL has no LOWER / ILIKE / = '' for numbers, dates, uuids...: a text filter on such a
149
+ # column (filter_type: :text on an integer) compares its text form.
150
+ def text_ref(column)
151
+ ref = qualified_column_ref(column)
152
+ type = select_column_type(column)
153
+ type.nil? || %i[string text].include?(type) ? ref : "CAST(#{ref} AS text)"
154
+ end
155
+
148
156
  # Boolean filter
149
157
  def apply_boolean_filter(column, params)
150
158
  return query unless params.is_a?(Hash)
@@ -245,6 +253,7 @@ module Infotable
245
253
  from = parse_date(scalar(params[:from] || params["from"]))
246
254
  to = parse_date(scalar(params[:to] || params["to"]))
247
255
  to = to.end_of_day if to && datetime
256
+ from = from.beginning_of_day if from && datetime # same zone as `to` (Time.zone), not UTC midnight
248
257
 
249
258
  result = query
250
259
  result = result.public_send(filter_method, "#{column_ref} >= ?", from) if from
@@ -10,7 +10,7 @@ module Infotable
10
10
  when :badge
11
11
  format_as_badge(value, true_text, false_text)
12
12
  when :icon
13
- format_as_icon(value)
13
+ format_as_icon(value, true_text, false_text)
14
14
  else
15
15
  value ? true_text : false_text
16
16
  end
@@ -26,11 +26,11 @@ module Infotable
26
26
  end
27
27
  end
28
28
 
29
- def format_as_icon(value)
29
+ def format_as_icon(value, true_text, false_text)
30
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>')
31
+ html_safe('<svg class="w-5 h-5 text-green-500" fill="currentColor" viewBox="0 0 20 20" role="img" aria-label="' + ERB::Util.html_escape(true_text) + '"><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
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>')
33
+ html_safe('<svg class="w-5 h-5 text-gray-400" fill="currentColor" viewBox="0 0 20 20" role="img" aria-label="' + ERB::Util.html_escape(false_text) + '"><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
34
  end
35
35
  end
36
36
  end
@@ -1,21 +1,13 @@
1
1
  module Infotable
2
2
  module Formatters
3
+ # formatter: :currency
4
+ # formatter_options: { unit: "$", precision: 2, delimiter: ",", separator: "." }
5
+ # Options you leave out come from I18n (`number.currency.format`) for the current locale.
3
6
  class CurrencyFormatter < BaseFormatter
4
7
  def format(value, record, options = {})
5
8
  return "" if value.blank?
6
9
 
7
- unit = options[:unit] || "$"
8
- precision = options[:precision] || 2
9
- delimiter = options[:delimiter] || ","
10
- separator = options[:separator] || "."
11
-
12
- number_to_currency(
13
- value,
14
- unit: unit,
15
- precision: precision,
16
- delimiter: delimiter,
17
- separator: separator
18
- )
10
+ number_to_currency(value, options.slice(:unit, :precision, :delimiter, :separator))
19
11
  end
20
12
  end
21
13
  end
@@ -10,16 +10,10 @@ module Infotable
10
10
  format = options[:format] || :default
11
11
 
12
12
  case format
13
- when :short
14
- value.strftime("%b %d, %Y")
15
- when :long
16
- value.strftime("%B %d, %Y")
17
- when :time
18
- value.strftime("%I:%M %p")
19
- when :datetime
20
- value.strftime("%b %d, %Y %I:%M %p")
13
+ when :short, :long, :time, :datetime
14
+ view_context.l(value, format: view_context.t("infotable.formatters.date.#{format}"))
21
15
  when :relative
22
- time_ago_in_words(value)
16
+ view_context.content_tag(:span, time_ago_in_words(value), title: value.strftime("%Y-%m-%d %H:%M"))
23
17
  when :dot_date
24
18
  value.strftime("%d.%m.%Y")
25
19
  when :default
@@ -0,0 +1,51 @@
1
+ module Infotable
2
+ module Formatters
3
+ # formatter: :duration
4
+ # Seconds as "1h 05m" (:short, default) or "1 hour 5 minutes" (:long).
5
+ # formatter_options: { format: :short, parts: 2 } # parts: how many units to show, from the largest
6
+ class DurationFormatter < BaseFormatter
7
+ UNITS = { d: 86_400, h: 3600, m: 60, s: 1 }.freeze
8
+
9
+ def format(value, record, options = {})
10
+ return "" if value.nil? || value == ""
11
+
12
+ seconds = Float(value.to_s, exception: false)
13
+ return value.to_s if seconds.nil? || seconds.negative?
14
+
15
+ parts = split(seconds.round).first(options.fetch(:parts, 2))
16
+ parts = [ [ :s, 0 ] ] if parts.empty?
17
+ options[:format].to_s == "long" ? long(parts) : short(parts)
18
+ end
19
+
20
+ private
21
+
22
+ # [[:h, 1], [:m, 5]]; units with nothing in them are left out
23
+ def split(seconds)
24
+ UNITS.filter_map do |unit, size|
25
+ count, seconds = seconds.divmod(size)
26
+ [ unit, count ] if count.positive?
27
+ end
28
+ end
29
+
30
+ def short(parts)
31
+ parts.each_with_index.map do |(unit, count), index|
32
+ number = index.zero? ? count.to_s : count.to_s.rjust(2, "0")
33
+ view_context.t("infotable.formatters.duration.short.#{unit}", count: number)
34
+ end.join(" ")
35
+ end
36
+
37
+ def long(parts)
38
+ parts.map { |unit, count| view_context.t("infotable.formatters.duration.long.#{unit}.#{plural_key(count)}", count: count) }.join(" ")
39
+ end
40
+
41
+ # The key is picked here, so Polish (1 / 2-4 / 5+) is right without rails-i18n's pluralization rules
42
+ def plural_key(count)
43
+ return count == 1 ? :one : :other unless I18n.locale.to_s == "pl"
44
+ return :one if count == 1
45
+ return :few if (2..4).cover?(count % 10) && !(12..14).cover?(count % 100)
46
+
47
+ :many
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,14 @@
1
+ module Infotable
2
+ module Formatters
3
+ # formatter: :filesize
4
+ # Bytes as a human size: 1536 => "1.5 KB". `formatter_options: { precision: 2 }` (significant digits)
5
+ class FilesizeFormatter < BaseFormatter
6
+ def format(value, record, options = {})
7
+ return "" if value.nil? || value == ""
8
+ return value.to_s if Float(value.to_s, exception: false).nil?
9
+
10
+ view_context.number_to_human_size(value, precision: options.fetch(:precision, 2), strip_insignificant_zeros: true)
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,31 @@
1
+ module Infotable
2
+ module Formatters
3
+ # formatter: :image
4
+ # A square thumbnail (use :avatar for round pictures); the value is the image URL.
5
+ # formatter_options: {
6
+ # size: :md, # :sm (32px), :md (48px), :lg (64px)
7
+ # alt: ->(record) { record.name }, # alt text, a String or a Proc
8
+ # link: true # open the full image in a new tab
9
+ # }
10
+ class ImageFormatter < BaseFormatter
11
+ SIZES = { sm: "h-8 w-8", md: "h-12 w-12", lg: "h-16 w-16" }.freeze
12
+
13
+ def format(value, record, options = {})
14
+ return "" unless image_url?(value)
15
+
16
+ alt = options[:alt].is_a?(Proc) ? view_context.instance_exec(record, &options[:alt]) : options[:alt]
17
+ size = SIZES.fetch((options[:size] || :md).to_sym, SIZES[:md])
18
+ image = tag.img(src: value.to_s, alt: alt.to_s, loading: "lazy", class: "#{size} rounded-md object-cover flex-none")
19
+ return image unless options[:link]
20
+
21
+ link_to(image, value.to_s, target: "_blank", rel: "noopener noreferrer")
22
+ end
23
+
24
+ private
25
+
26
+ def image_url?(value)
27
+ LinkFormatter.safe_url?(value) && value.to_s.match?(%r{\A(https?:)?//|\A/})
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,46 @@
1
+ module Infotable
2
+ module Formatters
3
+ # formatter: :number
4
+ # formatter_options: {
5
+ # precision: 2, # decimals; without it the number is shown as it is
6
+ # delimiter: ",", # thousands separator, default ","
7
+ # separator: ".", # decimal separator, default "."
8
+ # unit: "ms", # appended after the number
9
+ # compact: true, # 1.2k, 3.4M, 5B (precision defaults to 1)
10
+ # zero: "-" # shown instead of 0
11
+ # }
12
+ class NumberFormatter < BaseFormatter
13
+ COMPACT_STEPS = [ [ 1_000_000_000, "B" ], [ 1_000_000, "M" ], [ 1_000, "k" ] ].freeze
14
+
15
+ def format(value, record, options = {})
16
+ return "" if value.nil? || value == ""
17
+
18
+ number = Float(value.to_s, exception: false)
19
+ return value.to_s if number.nil?
20
+ return options[:zero].to_s if options.key?(:zero) && number.zero?
21
+
22
+ text = options[:compact] ? compact(value, number, options) : plain(value, options)
23
+ text = "#{text} #{options[:unit]}" if options[:unit].present?
24
+ content_tag(:span, text, class: "tabular-nums")
25
+ end
26
+
27
+ private
28
+
29
+ def plain(value, options)
30
+ delimiter = options.fetch(:delimiter, ",")
31
+ separator = options.fetch(:separator, ".")
32
+ return view_context.number_with_delimiter(value, delimiter: delimiter, separator: separator) unless options[:precision]
33
+
34
+ view_context.number_with_precision(value, precision: options[:precision], delimiter: delimiter, separator: separator)
35
+ end
36
+
37
+ def compact(value, number, options)
38
+ divisor, suffix = COMPACT_STEPS.find { |step, _| number.abs >= step }
39
+ return plain(value, options) unless divisor
40
+
41
+ view_context.number_with_precision(number / divisor, precision: options.fetch(:precision, 1),
42
+ separator: options.fetch(:separator, "."), strip_insignificant_zeros: true) + suffix
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,44 @@
1
+ module Infotable
2
+ module Formatters
3
+ # formatter: :tags (alias :list)
4
+ # The value is an Array, a relation or a comma separated string; every item becomes a small badge.
5
+ # formatter_options: {
6
+ # limit: 3, # show this many, then "+N" (the rest is in its tooltip)
7
+ # label: ->(item) { item.name }, # text of an item, defaults to item.to_s
8
+ # colors: { "admin" => "red" }, # item => badge colour, as in :badge (default gray)
9
+ # separator: ", " # plain text joined with this instead of badges
10
+ # }
11
+ class TagsFormatter < BaseFormatter
12
+ def format(value, record, options = {})
13
+ labels = items(value).map { label_for(_1, options) }.reject(&:blank?)
14
+ return "" if labels.empty?
15
+
16
+ return safe_join(labels, options[:separator].to_s) if options[:separator]
17
+
18
+ limit = options[:limit]
19
+ shown, rest = limit ? [ labels.first(limit), labels.drop(limit) ] : [ labels, [] ]
20
+ badges = shown.map { badge(_1, options) }
21
+ badges << content_tag(:span, "+#{rest.size}", title: rest.join(", "), class: "text-xs text-gray-500 dark:text-gray-400") if rest.any?
22
+ content_tag(:div, safe_join(badges), class: "flex flex-wrap items-center gap-1")
23
+ end
24
+
25
+ private
26
+
27
+ def items(value)
28
+ return [] if value.nil?
29
+ return value.split(",").map(&:strip) if value.is_a?(String)
30
+
31
+ value.respond_to?(:to_a) ? value.to_a : [ value ]
32
+ end
33
+
34
+ def label_for(item, options)
35
+ label = options[:label].is_a?(Proc) ? view_context.instance_exec(item, &options[:label]) : item
36
+ label.to_s
37
+ end
38
+
39
+ def badge(text, options)
40
+ BadgeFormatter.new(view_context).format(text, nil, label: text, size: :small, colors: options[:colors])
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,19 @@
1
+ module Infotable
2
+ module Formatters
3
+ # formatter: :truncate
4
+ # Long text cut to `length` characters; the full text stays in the cell's tooltip.
5
+ # formatter_options: { length: 60, omission: "…" }
6
+ class TruncateFormatter < BaseFormatter
7
+ def format(value, record, options = {})
8
+ return "" if value.blank?
9
+
10
+ text = value.to_s
11
+ length = options.fetch(:length, 60)
12
+ return text if text.length <= length
13
+
14
+ shortened = view_context.truncate(text, length: length, omission: options.fetch(:omission, "…"))
15
+ content_tag(:span, shortened, title: text)
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,94 @@
1
+ require "generators/infotable/table/table_generator"
2
+
3
+ module Infotable
4
+ module Mcp
5
+ # What the host app looks like: models and their columns, tables that exist, the DSL of this gem version.
6
+ module Introspection
7
+ module_function
8
+
9
+ def models
10
+ Rails.application.eager_load!
11
+ ActiveRecord::Base.descendants.reject { _1.abstract_class? || _1.name.nil? || !_1.table_exists? }.sort_by(&:name).map { describe_model(_1) }
12
+ end
13
+
14
+ def describe_model(model)
15
+ {
16
+ name: model.name,
17
+ table: model.table_name,
18
+ columns: model.columns.to_h { [ _1.name, _1.type.to_s ] },
19
+ enums: model.defined_enums.transform_values(&:keys),
20
+ associations: model.reflect_on_all_associations.map { { name: _1.name, kind: _1.macro, model: _1.options[:class_name] || _1.name.to_s.classify } }
21
+ }
22
+ end
23
+
24
+ def tables
25
+ Rails.application.eager_load!
26
+ Infotable::Base.descendants.reject { _1.name.nil? }.sort_by(&:name).map { describe_table(_1) }
27
+ end
28
+
29
+ def describe_table(table)
30
+ {
31
+ class: table.name,
32
+ model: (table.base_query.model.name rescue nil),
33
+ columns: table.columns.map { |col| col.slice(:name, :type, :sortable, :searchable, :filterable, :filter_type, :visible, :pinned).compact },
34
+ per_page: table.pagination_settings[:per_page],
35
+ default_sort: table.sort_settings.presence,
36
+ toolbar: table.toolbar_mode,
37
+ batch_actions: table.batch_action_list.map { _1[:name] },
38
+ row_click: table.row_click?,
39
+ infinite_scroll: table.infinite_scroll_enabled?,
40
+ countless: table.countless?,
41
+ no_authorize_block: table.table_authorize.nil?
42
+ }
43
+ end
44
+
45
+ # The DSL as this gem version knows it, read from the code so it cannot drift from the docs
46
+ def dsl
47
+ {
48
+ version: Infotable::VERSION,
49
+ column_options: Infotable::Base::COLUMN_OPTIONS,
50
+ column_types: Infotable::Generators::TableGenerator::TYPE_MAP.values.map { _1[:type] }.uniq + %i[badge currency percent link avatar action],
51
+ filter_types: %i[text boolean number date select],
52
+ formatters: Infotable::Formatters.built_in.keys,
53
+ toolbar_modes: %i[header rail],
54
+ table_settings: %i[query query_sql per_page per_page_options default_sort infinite_scroll countless url_state resizable_columns
55
+ toolbar row_click row_link row_class row_data row_size checkbox_column batch_actions authorize export_columns
56
+ table_title table_subtitle table_height nil_value empty_value],
57
+ batch_action_options: { required: %i[url], optional: %i[label method confirm color], methods: Infotable::BatchActions::METHODS, colors: Infotable::BatchActions::COLORS },
58
+ generate_table_options: Infotable::Generators::TableGenerator.class_options.keys
59
+ }
60
+ end
61
+
62
+ # Questions to put to the user before generating a table, each with a default proposed from the model
63
+ def table_options(model_name)
64
+ model = model_name.to_s.safe_constantize
65
+ return nil unless model.is_a?(Class) && model < ActiveRecord::Base
66
+
67
+ columns = model.column_names.reject { Infotable::Generators::TableGenerator::SENSITIVE.match?(_1) }
68
+ enums = model.defined_enums.keys
69
+ sort = columns.include?("created_at") ? "created_at:desc" : "#{model.primary_key}:asc"
70
+ belongs_to = model.reflect_on_all_associations(:belongs_to).map(&:name)
71
+
72
+ {
73
+ model: model.name,
74
+ notes: [
75
+ ("enum columns (#{enums.join(', ')}) become badges with a select filter" if enums.any?),
76
+ ("belongs_to #{belongs_to.join(', ')}: generated as a commented hint, a column needs a join" if belongs_to.any?),
77
+ "foreign keys and sensitive columns (password, token...) are left out"
78
+ ].compact,
79
+ questions: [
80
+ { param: "only", ask: "Which columns?", choices: columns, default: "all of the choices" },
81
+ { param: "sort", ask: "Default sort (column:direction)?", choices: columns, default: sort },
82
+ { param: "per_page", ask: "Rows per page?", choices: [ 10, 25, 50, 100 ], default: 25 },
83
+ { param: "toolbar", ask: "Toolbar: header bar or slim rail?", choices: %w[header rail], default: "header" },
84
+ { param: "checkbox", ask: "Row selection column?", default: false },
85
+ { param: "row_click", ask: "Clicking a row opens it (needs row_link)?", default: false },
86
+ { param: "infinite_scroll", ask: "Infinite scroll instead of pages?", default: false },
87
+ { param: "countless", ask: "Skip the total count (tables over ~1M rows)?", default: false },
88
+ { param: "authorize", ask: "Who may see the table? A Ruby expression over `controller`", default: "controller.current_user.present?" }
89
+ ]
90
+ }
91
+ end
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,26 @@
1
+ module Infotable
2
+ module Mcp
3
+ module Server
4
+ TOOLS = [ ListModels, ListTables, DescribeDsl, TableOptions, GenerateTable, ValidateTable ].freeze
5
+
6
+ module_function
7
+
8
+ def build
9
+ readme = ::MCP::Resource.new(uri: "infotable://readme", name: "infotable-readme", title: "InfoTable README",
10
+ description: "Full documentation of the table DSL", mime_type: "text/markdown")
11
+ server = ::MCP::Server.new(
12
+ name: "infotable", version: Infotable::VERSION, tools: TOOLS, prompts: [ NewTablePrompt ], resources: [ readme ],
13
+ instructions: "Creates InfoTable tables in this Rails app. Start with list_models and table_options; ask the user for the settings before generate_table."
14
+ )
15
+ server.resources_read_handler do |params|
16
+ [ { uri: params[:uri], mimeType: "text/markdown", text: File.read(Infotable.root.join("README.md")) } ]
17
+ end
18
+ server
19
+ end
20
+
21
+ def run
22
+ ::MCP::Server::Transports::StdioTransport.new(build).open
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,80 @@
1
+ module Infotable
2
+ module Mcp
3
+ # Loads a table and looks for mistakes before the app does: columns the model lacks,
4
+ # a default sort on a missing column, a query that raises. Optionally returns a few rows.
5
+ class TableChecker
6
+ def initialize(name: nil, source: nil, preview: 0)
7
+ @name = name
8
+ @source = source
9
+ @preview = preview.to_i.clamp(0, 20)
10
+ end
11
+
12
+ def call
13
+ table = load_table
14
+ problems = []
15
+ model = nil
16
+
17
+ begin
18
+ model = table.base_query.model
19
+ rescue StandardError => e
20
+ return { ok: false, problems: [ "query raises #{e.class}: #{e.message}" ] }
21
+ end
22
+
23
+ problems.concat(column_problems(table, model))
24
+ problems.concat(sort_problems(table))
25
+ problems << "no authorize block: anyone who can reach the engine routes can read this table" if table.table_authorize.nil?
26
+
27
+ rows = preview_rows(table, problems)
28
+ { ok: problems.none? { !_1.start_with?("no authorize") }, table: table.name, model: model.name, problems: problems, preview: rows }.compact
29
+ end
30
+
31
+ private
32
+
33
+ def load_table
34
+ if @source
35
+ mod = Module.new
36
+ mod.module_eval(@source.sub(/class \S+ </, "class CheckedTable <"), "infotable_mcp_check.rb")
37
+ mod.const_get(:CheckedTable)
38
+ else
39
+ reload_app
40
+ Infotable::TableResolver.resolve(@name)
41
+ end
42
+ end
43
+
44
+ # A table written a moment ago is not known to the autoloader yet
45
+ def reload_app
46
+ Rails.application.reloader.reload! if Rails.application.config.enable_reloading
47
+ rescue StandardError
48
+ nil
49
+ end
50
+
51
+ def column_problems(table, model)
52
+ table.columns.filter_map do |col|
53
+ next if col[:value] || col[:virtual] || col[:sql] || %i[action actions].include?(col[:type])
54
+ next if model.column_names.include?(col[:name].to_s) || model.method_defined?(col[:name])
55
+
56
+ "column :#{col[:name]} is not an attribute of #{model.name}: add value:, sql: or virtual:"
57
+ end
58
+ end
59
+
60
+ def sort_problems(table)
61
+ column = table.sort_settings[:column]
62
+ return [] if column.nil? || table.columns.any? { _1[:name].to_s == column.to_s }
63
+
64
+ [ "default_sort :#{column} is not a defined column" ]
65
+ end
66
+
67
+ def preview_rows(table, problems)
68
+ return nil if @preview.zero?
69
+
70
+ columns = table.visible_columns.reject { %i[action actions].include?(_1[:type]) }
71
+ table.base_query.limit(@preview).map do |record|
72
+ columns.to_h { |col| [ col[:name], table.cell_value(record, col).to_s ] }
73
+ end
74
+ rescue StandardError => e
75
+ problems << "query raises #{e.class}: #{e.message}"
76
+ nil
77
+ end
78
+ end
79
+ end
80
+ end