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.
- checksums.yaml +4 -4
- data/README.md +179 -11
- data/app/assets/javascripts/infotable/controllers/drawer_controller.js +74 -378
- data/app/assets/javascripts/infotable/controllers/table_controller.js +112 -120
- data/app/assets/javascripts/infotable/vendor/sortable.esm.js +3363 -0
- data/app/controllers/infotable/tables_controller.rb +16 -5
- data/app/helpers/infotable/tables_helper.rb +10 -48
- data/app/views/infotable/tables/_column_visibility.html.erb +4 -4
- data/app/views/infotable/tables/_end_indicator.html.erb +8 -0
- data/app/views/infotable/tables/_filter_drawer.html.erb +6 -6
- data/app/views/infotable/tables/_next_page_frame.html.erb +1 -9
- data/app/views/infotable/tables/_pagination.html.erb +4 -7
- data/app/views/infotable/tables/_table.html.erb +1 -1
- data/app/views/infotable/tables/_table_rows.html.erb +9 -2
- data/app/views/infotable/tables/filters/_boolean.html.erb +1 -1
- data/app/views/infotable/tables/filters/_range.html.erb +1 -1
- data/app/views/infotable/tables/filters/_select.html.erb +2 -2
- data/app/views/infotable/tables/filters/_text.html.erb +2 -2
- data/app/views/infotable/tables/show.html.erb +2 -2
- data/config/importmap.rb +2 -2
- data/config/locales/de.yml +126 -0
- data/config/locales/el.yml +126 -0
- data/config/locales/en.yml +18 -4
- data/config/locales/es.yml +126 -0
- data/config/locales/fr.yml +126 -0
- data/config/locales/it.yml +126 -0
- data/config/locales/pl.yml +18 -4
- data/exe/infotable-mcp +13 -0
- data/lib/generators/infotable/install/templates/initializer.rb +5 -3
- data/lib/generators/infotable/table/table_generator.rb +23 -1
- data/lib/generators/infotable/table/templates/table.rb.tt +8 -1
- data/lib/infotable/base.rb +62 -4
- data/lib/infotable/configuration.rb +1 -1
- data/lib/infotable/engine.rb +1 -8
- data/lib/infotable/filter_processor.rb +10 -1
- data/lib/infotable/formatters/boolean_formatter.rb +4 -4
- data/lib/infotable/formatters/currency_formatter.rb +4 -12
- data/lib/infotable/formatters/date_formatter.rb +3 -9
- data/lib/infotable/formatters/duration_formatter.rb +51 -0
- data/lib/infotable/formatters/filesize_formatter.rb +14 -0
- data/lib/infotable/formatters/image_formatter.rb +31 -0
- data/lib/infotable/formatters/number_formatter.rb +46 -0
- data/lib/infotable/formatters/tags_formatter.rb +44 -0
- data/lib/infotable/formatters/truncate_formatter.rb +19 -0
- data/lib/infotable/mcp/introspection.rb +94 -0
- data/lib/infotable/mcp/server.rb +26 -0
- data/lib/infotable/mcp/table_checker.rb +80 -0
- data/lib/infotable/mcp/table_writer.rb +66 -0
- data/lib/infotable/mcp/tools.rb +122 -0
- data/lib/infotable/mcp.rb +30 -0
- data/lib/infotable/table_resolver.rb +9 -0
- data/lib/infotable/version.rb +1 -1
- data/lib/infotable.rb +22 -0
- metadata +53 -4
data/lib/infotable/base.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
138
|
-
|
|
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 =
|
|
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
|
data/lib/infotable/engine.rb
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|