infotable 1.5.0 → 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 (41) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +144 -10
  3. data/app/assets/javascripts/infotable/controllers/drawer_controller.js +19 -18
  4. data/app/assets/javascripts/infotable/controllers/table_controller.js +42 -23
  5. data/app/controllers/infotable/tables_controller.rb +1 -1
  6. data/app/helpers/infotable/tables_helper.rb +10 -25
  7. data/app/views/infotable/tables/_filter_drawer.html.erb +6 -6
  8. data/app/views/infotable/tables/_pagination.html.erb +4 -7
  9. data/app/views/infotable/tables/_table.html.erb +1 -1
  10. data/app/views/infotable/tables/filters/_boolean.html.erb +1 -1
  11. data/app/views/infotable/tables/filters/_range.html.erb +1 -1
  12. data/app/views/infotable/tables/filters/_select.html.erb +2 -2
  13. data/app/views/infotable/tables/filters/_text.html.erb +2 -2
  14. data/config/locales/de.yml +126 -0
  15. data/config/locales/el.yml +126 -0
  16. data/config/locales/en.yml +18 -4
  17. data/config/locales/es.yml +126 -0
  18. data/config/locales/fr.yml +126 -0
  19. data/config/locales/it.yml +126 -0
  20. data/config/locales/pl.yml +18 -4
  21. data/exe/infotable-mcp +13 -0
  22. data/lib/generators/infotable/table/table_generator.rb +23 -1
  23. data/lib/generators/infotable/table/templates/table.rb.tt +8 -1
  24. data/lib/infotable/base.rb +5 -4
  25. data/lib/infotable/formatters/currency_formatter.rb +4 -12
  26. data/lib/infotable/formatters/date_formatter.rb +3 -9
  27. data/lib/infotable/formatters/duration_formatter.rb +51 -0
  28. data/lib/infotable/formatters/filesize_formatter.rb +14 -0
  29. data/lib/infotable/formatters/image_formatter.rb +31 -0
  30. data/lib/infotable/formatters/number_formatter.rb +46 -0
  31. data/lib/infotable/formatters/tags_formatter.rb +44 -0
  32. data/lib/infotable/formatters/truncate_formatter.rb +19 -0
  33. data/lib/infotable/mcp/introspection.rb +94 -0
  34. data/lib/infotable/mcp/server.rb +26 -0
  35. data/lib/infotable/mcp/table_checker.rb +80 -0
  36. data/lib/infotable/mcp/table_writer.rb +66 -0
  37. data/lib/infotable/mcp/tools.rb +122 -0
  38. data/lib/infotable/mcp.rb +30 -0
  39. data/lib/infotable/version.rb +1 -1
  40. data/lib/infotable.rb +22 -0
  41. metadata +51 -4
@@ -0,0 +1,126 @@
1
+ it:
2
+ infotable:
3
+ table:
4
+ buttons:
5
+ export_csv: "Esporta CSV"
6
+ filters: "Filtri"
7
+ exporting: "Esportazione..."
8
+ refresh: "Aggiorna"
9
+ columns: "Colonne"
10
+ sort_hint: "Clicca per ordinare, Maiusc+clic aggiunge una colonna all'ordinamento"
11
+ loading:
12
+ initial: "Caricamento dati..."
13
+ more_items: "Caricamento di altri elementi..."
14
+ all_loaded: "Tutti gli elementi sono stati caricati"
15
+ resize_column: "ridimensiona colonna"
16
+ empty_state:
17
+ title: "Nessun dato trovato"
18
+ description: "Aggiungi dei record per vederli qui."
19
+ filtered_title: "Nessuna riga corrisponde ai filtri"
20
+ filtered_description: "Rimuovi un filtro o cerca qualcos'altro."
21
+
22
+ drawer:
23
+ label: "Filtri e colonne"
24
+ panel_title:
25
+ customize_columns: "Personalizza colonne"
26
+ filters: "Filtri"
27
+ columns: "Colonne"
28
+ tabs:
29
+ filters: "Filtri"
30
+ columns: "Colonne"
31
+
32
+ columns:
33
+ instructions: "Trascina per riordinare, fissa le colonne e mostra/nascondi:"
34
+ pin:
35
+ left: "Fissa a sinistra"
36
+ right: "Fissa a destra"
37
+ actions:
38
+ select_all: "Seleziona tutto"
39
+ deselect_all: "Deseleziona tutto"
40
+ reset_to_defaults: "🔄 Ripristina predefiniti"
41
+
42
+ filters:
43
+ actions:
44
+ clear_all: "Cancella tutto"
45
+ apply_filters: "Applica filtri"
46
+ clear_filter: "Cancella questo filtro"
47
+ active:
48
+ search: "Ricerca: «%{query}»"
49
+ remove: "Rimuovi il filtro %{filter}"
50
+ clear_all: "Cancella tutti i filtri"
51
+ from: "Da"
52
+ to: "A"
53
+ min: "Min"
54
+ max: "Max"
55
+ enter_value: "Inserisci un valore..."
56
+ all: "Tutti"
57
+ multi_select_hint: "Tieni premuto Ctrl/Cmd per selezionarne più di uno"
58
+ options_truncated: "Mostrate le prime %{shown} opzioni su %{total}"
59
+ boolean:
60
+ "true": "Vero"
61
+ "false": "Falso"
62
+ empty: "Vuoto"
63
+ not_empty: "Non vuoto"
64
+ text_ops:
65
+ contains: "Contiene"
66
+ exact: "Corrispondenza esatta"
67
+ not_contain: "Non contiene"
68
+ starts_with: "Inizia con"
69
+ ends_with: "Finisce con"
70
+ is_empty: "È vuoto"
71
+ is_not_empty: "Non è vuoto"
72
+ range_ops:
73
+ between: "Tra"
74
+ number_ops:
75
+ eq: "Uguale a"
76
+ neq: "Diverso da"
77
+ gt: "Maggiore di"
78
+ gte: "Maggiore o uguale a"
79
+ lt: "Minore di"
80
+ lte: "Minore o uguale a"
81
+ date_ops:
82
+ "on": "Il"
83
+ before: "Prima del"
84
+ after: "Dopo il"
85
+
86
+ formatters:
87
+ ago: "%{time} fa"
88
+ date:
89
+ short: "%d %b %Y"
90
+ long: "%d %B %Y"
91
+ time: "%H:%M"
92
+ datetime: "%d %b %Y %H:%M"
93
+ action: "Azione"
94
+ boolean:
95
+ "true": "Sì"
96
+ "false": "No"
97
+ duration:
98
+ short:
99
+ d: "%{count} g"
100
+ h: "%{count} h"
101
+ m: "%{count} min"
102
+ s: "%{count} s"
103
+ long:
104
+ d: { one: "%{count} giorno", few: "%{count} giorni", many: "%{count} giorni", other: "%{count} giorni" }
105
+ h: { one: "%{count} ora", few: "%{count} ore", many: "%{count} ore", other: "%{count} ore" }
106
+ m: { one: "%{count} minuto", few: "%{count} minuti", many: "%{count} minuti", other: "%{count} minuti" }
107
+ s: { one: "%{count} secondo", few: "%{count} secondi", many: "%{count} secondi", other: "%{count} secondi" }
108
+
109
+ batch:
110
+ selected: "%{count} selezionati"
111
+ clear: "Cancella selezione"
112
+ select_all: "Seleziona tutte le righe"
113
+ select_row: "Seleziona riga"
114
+ select_all_matching: "Seleziona tutti i %{count} risultati"
115
+ truncated: "Sono selezionate solo le prime %{max} righe"
116
+
117
+ search:
118
+ placeholder: "Cerca..."
119
+ label: "Cerca nella tabella"
120
+
121
+ pagination:
122
+ summary_html: "Visualizzazione da %{from} a %{to} di %{total} risultati"
123
+ summary_no_total_html: "Visualizzazione da %{from} a %{to}"
124
+ rows_per_page: "Righe per pagina:"
125
+ previous: "Precedente"
126
+ next: "Successivo"
@@ -85,10 +85,26 @@ pl:
85
85
 
86
86
  formatters:
87
87
  ago: "%{time} temu"
88
+ date:
89
+ short: "%d %b %Y"
90
+ long: "%d %B %Y"
91
+ time: "%H:%M"
92
+ datetime: "%d %b %Y %H:%M"
88
93
  action: "Akcja"
89
94
  boolean:
90
95
  "true": "Tak"
91
96
  "false": "Nie"
97
+ duration:
98
+ short:
99
+ d: "%{count} d"
100
+ h: "%{count} godz."
101
+ m: "%{count} min"
102
+ s: "%{count} s"
103
+ long:
104
+ d: { one: "%{count} dzień", few: "%{count} dni", many: "%{count} dni", other: "%{count} dni" }
105
+ h: { one: "%{count} godzina", few: "%{count} godziny", many: "%{count} godzin", other: "%{count} godzin" }
106
+ m: { one: "%{count} minuta", few: "%{count} minuty", many: "%{count} minut", other: "%{count} minut" }
107
+ s: { one: "%{count} sekunda", few: "%{count} sekundy", many: "%{count} sekund", other: "%{count} sekund" }
92
108
 
93
109
  batch:
94
110
  selected: "Zaznaczono: %{count}"
@@ -103,10 +119,8 @@ pl:
103
119
  label: "Szukaj w tabeli"
104
120
 
105
121
  pagination:
106
- showing: "Wyświetlanie"
107
- to: "do"
108
- of: "z"
109
- results: "wyników"
122
+ summary_html: "Wyświetlanie %{from} do %{to} z %{total} wyników"
123
+ summary_no_total_html: "Wyświetlanie %{from} do %{to}"
110
124
  rows_per_page: "Wierszy na stronę:"
111
125
  previous: "Poprzednia"
112
126
  next: "Następna"
data/exe/infotable-mcp ADDED
@@ -0,0 +1,13 @@
1
+ #!/usr/bin/env ruby
2
+ # MCP server (stdio) that lets an AI assistant create InfoTable tables in the host app:
3
+ # bundle exec infotable-mcp
4
+ # Run it from the Rails app's root, see "MCP server" in the README.
5
+ require "bundler/setup"
6
+
7
+ environment = File.expand_path("config/environment", Dir.pwd)
8
+ abort "infotable-mcp: run it from the root of a Rails app (no config/environment.rb in #{Dir.pwd})" unless File.exist?("#{environment}.rb")
9
+
10
+ require environment
11
+ require "infotable/mcp"
12
+
13
+ Infotable::Mcp::Server.run
@@ -9,6 +9,14 @@ module Infotable
9
9
 
10
10
  class_option :only, type: :array, default: [], desc: "Only these columns"
11
11
  class_option :skip, type: :array, default: [], desc: "Leave these columns out"
12
+ class_option :per_page, type: :numeric, default: 25, desc: "Rows per page"
13
+ class_option :sort, type: :string, desc: "Default sort as column:direction, e.g. created_at:desc"
14
+ class_option :toolbar, type: :string, desc: "Toolbar mode: header or rail"
15
+ class_option :row_click, type: :boolean, default: false, desc: "Clicking a row opens its row_link"
16
+ class_option :infinite_scroll, type: :boolean, default: false, desc: "Infinite scroll instead of pages"
17
+ class_option :countless, type: :boolean, default: false, desc: "Skip the total count (big tables)"
18
+ class_option :checkbox, type: :boolean, default: false, desc: "Row selection column"
19
+ class_option :authorize, type: :string, desc: "Ruby expression over `controller` that decides who sees the table"
12
20
 
13
21
  # Attributes that must never end up in a table by accident
14
22
  SENSITIVE = /password|digest|token|secret|otp|encrypted/
@@ -82,9 +90,23 @@ module Infotable
82
90
  @model.reflect_on_all_associations(:belongs_to).map(&:name)
83
91
  end
84
92
 
93
+ # Table-level settings chosen with the options above, one DSL line each
94
+ def setting_lines
95
+ lines = []
96
+ lines << "toolbar :#{options[:toolbar]}" if options[:toolbar].present?
97
+ lines << "infinite_scroll true" if options[:infinite_scroll]
98
+ lines << "countless" if options[:countless]
99
+ lines << "checkbox_column" if options[:checkbox]
100
+ lines << "row_click" if options[:row_click]
101
+ lines
102
+ end
103
+
85
104
  def default_sort_line
86
105
  names = column_specs.map { _1[:name] }
87
- if names.include?("created_at")
106
+ if options[:sort].present?
107
+ column, direction = options[:sort].split(":")
108
+ "default_sort :#{column}, :#{direction == 'desc' ? 'desc' : 'asc'}"
109
+ elsif names.include?("created_at")
88
110
  "default_sort :created_at, :desc"
89
111
  elsif names.any?
90
112
  "default_sort :#{names.first}, :asc"
@@ -19,8 +19,15 @@ class <%= table_class_name %> < Infotable::Base
19
19
  <% end -%>
20
20
  <% end -%>
21
21
 
22
- per_page 25
22
+ per_page <%= options[:per_page] %>
23
23
  <% if default_sort_line %>
24
24
  <%= default_sort_line %>
25
25
  <% end -%>
26
+ <% setting_lines.each do |line| -%>
27
+ <%= line %>
28
+ <% end -%>
29
+ <% if options[:authorize].present? %>
30
+
31
+ authorize { |controller| <%= options[:authorize] %> }
32
+ <% end -%>
26
33
  end
@@ -1,5 +1,10 @@
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
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
 
@@ -171,10 +176,6 @@ module Infotable
171
176
  end
172
177
 
173
178
  # Define a column
174
- COLUMN_OPTIONS = %i[label type sortable searchable filterable visible exportable pinned virtual virtual_sql
175
- sql sort_sql value includes formatter formatter_options actions filter_type filter_options
176
- filter_options_cache filter_options_limit filter_multi_select width].freeze
177
-
178
179
  def column(name, **options)
179
180
  @table_columns ||= []
180
181
  unknown = options.keys - COLUMN_OPTIONS
@@ -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