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
@@ -27,6 +27,10 @@ module Infotable
27
27
  format.turbo_stream {
28
28
  # For infinite scroll - return turbo stream to append rows and replace pagination frame
29
29
  # Following the guide's "Frames + Streams" pattern
30
+ if @page_out_of_range # nothing left: appending the first page again would repeat its rows
31
+ return render turbo_stream: turbo_stream.replace("#{params[:table_name]}_pagination", partial: "infotable/tables/end_indicator")
32
+ end
33
+
30
34
  streams = [
31
35
  turbo_stream.append(
32
36
  "#{params[:table_name]}_tbody",
@@ -48,7 +52,7 @@ module Infotable
48
52
  # No more pages - replace with end indicator
49
53
  streams << turbo_stream.replace(
50
54
  "#{params[:table_name]}_pagination",
51
- '<div class="infotable-end-indicator border-t border-gray-100 bg-white px-6 py-8 text-center"><div class="inline-flex items-center gap-2 text-sm text-gray-500"><svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg><span class="font-medium">All items loaded</span></div></div>'.html_safe
55
+ partial: "infotable/tables/end_indicator"
52
56
  )
53
57
  end
54
58
 
@@ -139,6 +143,7 @@ module Infotable
139
143
  # Apply pagination
140
144
  @pagy, @records = paginate(@page)
141
145
  rescue Pagy::RangeError # page past the end: show the first page
146
+ @page_out_of_range = true
142
147
  @pagy, @records = paginate(1)
143
148
  end
144
149
 
@@ -207,7 +212,7 @@ module Infotable
207
212
  def add_sort_tiebreaker(query, sort_columns)
208
213
  model = query.model
209
214
  pk = model.primary_key
210
- return query if pk.blank? || sort_columns.map(&:to_s).include?(pk) || query.distinct_value
215
+ return query if pk.blank? || sort_columns.map(&:to_s).include?(pk) || query.distinct_value || query.group_values.any?
211
216
 
212
217
  query.order(Arel.sql("#{model.table_name}.#{pk} ASC"))
213
218
  end
@@ -228,9 +233,15 @@ module Infotable
228
233
  requested.positive? ? [ requested, MAX_PER_PAGE ].min : default
229
234
  end
230
235
 
231
- # Guard against spreadsheet formula injection
236
+ PLAIN_NUMBER = /\A[+-]?\d+([.,]\d+)?\z/
237
+
238
+ # Guard against spreadsheet formula injection. Plain numbers ("-5", "+48123456789") are
239
+ # not formulas and are left alone.
232
240
  def csv_safe(value)
233
- value.is_a?(String) && value.match?(/\A[=+\-@\t\r]/) ? "'#{value}" : value
241
+ return value unless value.is_a?(String) && value.match?(/\A[=+\-@\t\r]/)
242
+ return value if value.match?(PLAIN_NUMBER)
243
+
244
+ "'#{value}"
234
245
  end
235
246
 
236
247
  def render_table_frame
@@ -302,7 +313,7 @@ module Infotable
302
313
  when :boolean
303
314
  value ? "true" : "false"
304
315
  else
305
- value.to_s
316
+ value.is_a?(Array) ? value.join(", ") : value.to_s
306
317
  end
307
318
  end
308
319
  end
@@ -89,29 +89,6 @@ module Infotable
89
89
  end
90
90
  end
91
91
 
92
- # Build export params with current filters, sort, and visible columns
93
- def build_export_params(table_name, table_class)
94
- params_hash = {
95
- table_name: table_name,
96
- sort_column: scalar_request_param(:sort_column),
97
- sort_direction: scalar_request_param(:sort_direction),
98
- sort: scalar_request_param(:sort)
99
- }
100
-
101
- # Add filters if present
102
- if params[:filters].present?
103
- params_hash[:filters] = params[:filters]
104
- end
105
-
106
- # Get visible columns from localStorage via JavaScript or use defaults
107
- # For now, we'll pass all visible columns and let the controller handle it
108
- # The JavaScript will need to update the link dynamically
109
- visible_column_names = table_class.visible_columns.map { |c| c[:name].to_s }
110
- params_hash[:visible_columns] = visible_column_names.join(",")
111
-
112
- params_hash.compact
113
- end
114
-
115
92
  private
116
93
 
117
94
  def resolve_table_class(table_name)
@@ -143,29 +120,11 @@ module Infotable
143
120
  formatter_name = column[:formatter]
144
121
  formatter_options = column[:formatter_options] || {}
145
122
 
146
- formatter_class = case formatter_name
147
- when :date, :datetime
148
- Infotable::Formatters::DateFormatter
149
- when :currency
150
- Infotable::Formatters::CurrencyFormatter
151
- when :boolean
152
- Infotable::Formatters::BooleanFormatter
153
- when :badge, :status
154
- Infotable::Formatters::BadgeFormatter
155
- when :action, :actions
156
- Infotable::Formatters::ActionFormatter
157
- when :link
158
- Infotable::Formatters::LinkFormatter
159
- when :avatar
160
- Infotable::Formatters::AvatarFormatter
161
- when :percent
162
- Infotable::Formatters::PercentFormatter
163
- else
164
- # Custom formatter: :avatar => AvatarFormatter (or Namespaced::AvatarFormatter)
123
+ formatter_class = Infotable::Formatters.built_in[formatter_name.to_sym] ||
124
+ # Custom formatter: :stars => StarsFormatter (or Namespaced::StarsFormatter)
165
125
  "#{formatter_name.to_s.camelize}Formatter".safe_constantize ||
166
- raise(Infotable::Error, "Unknown formatter :#{formatter_name} for column :#{column[:name]}. " \
167
- "Built-ins: date, currency, boolean, badge, action, link, avatar, percent; custom: define #{formatter_name.to_s.camelize}Formatter.")
168
- end
126
+ raise(Infotable::Error, "Unknown formatter :#{formatter_name} for column :#{column[:name]}. " \
127
+ "Built-ins: #{Infotable::Formatters.built_in.keys.join(', ')}; custom: define #{formatter_name.to_s.camelize}Formatter.")
169
128
 
170
129
  formatter_class.new(self).format(value, record, formatter_options)
171
130
  end
@@ -262,14 +221,17 @@ module Infotable
262
221
  DEFAULT_DRAWER_WIDTH = "26rem".freeze
263
222
 
264
223
  # HTML attributes for the slide-in drawer (side and width from config)
265
- def infotable_drawer_attributes(config)
224
+ # `rail`: the drawer sits flush against the rail, so no rounded corners or heavy shadow
225
+ def infotable_drawer_attributes(config, rail: false)
266
226
  side = config.drawer_position.to_s == "left" ? "left" : "right"
267
227
  width = config.drawer_width.to_s.match?(DRAWER_WIDTH_FORMAT) ? config.drawer_width.to_s : DEFAULT_DRAWER_WIDTH
268
228
  hidden = side == "left" ? "-100%" : "100%"
269
229
 
230
+ # With a rail the drawer must line up with the card's inner edge: inset it by the card's 1px border
231
+ inset = rail ? "1px" : "0"
270
232
  {
271
- style: "#{side}: 0; width: #{width}; max-width: 90%; transform: translateX(#{hidden});",
272
- class: "absolute top-0 bottom-0 bg-white dark:bg-gray-900 #{side == 'left' ? 'border-r rounded-r-xl' : 'border-l rounded-l-xl'} border-gray-200 dark:border-gray-700 shadow-xl transition-all duration-300 ease-in-out z-50",
233
+ style: "#{side}: #{inset}; #{"top: 1px; bottom: 1px; " if rail}width: #{width}; max-width: 90%; transform: translateX(#{hidden});",
234
+ class: "absolute top-0 bottom-0 bg-white dark:bg-gray-900 #{side == 'left' ? 'border-r' : 'border-l'}#{side == 'left' ? (rail ? ' rounded-l-[11px]' : ' rounded-r-xl') : (rail ? ' rounded-r-[11px]' : ' rounded-l-xl')} border-gray-200 dark:border-gray-700 #{rail ? 'shadow-none' : 'shadow-xl'} transition-all duration-300 ease-in-out z-50",
273
235
  role: "dialog",
274
236
  inert: true, # closed drawer is off-screen: keep it out of the tab order (JS toggles it)
275
237
  aria: { label: I18n.t("infotable.drawer.label"), hidden: true },
@@ -50,8 +50,8 @@
50
50
  data-pin-direction="left"
51
51
  title="<%= t('infotable.columns.pin.left') %>"
52
52
  class="pin-btn pin-left w-7 h-7 rounded flex items-center justify-center <%= column[:pinned] == :left ? 'bg-indigo-100 dark:bg-indigo-900/40 text-indigo-600 dark:text-indigo-400' : 'text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-gray-600 dark:hover:text-gray-300' %> transition-colors">
53
- <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
54
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"></path>
53
+ <svg class="w-4 h-4 -rotate-45" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" viewBox="0 0 24 24">
54
+ <path d="M12 17v5"/><path d="M9 10.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24V16a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V7a1 1 0 0 1 1-1 2 2 0 0 0 0-4H8a2 2 0 0 0 0 4 1 1 0 0 1 1 1z"/>
55
55
  </svg>
56
56
  </button>
57
57
  <%# Pin Right %>
@@ -61,8 +61,8 @@
61
61
  data-pin-direction="right"
62
62
  title="<%= t('infotable.columns.pin.right') %>"
63
63
  class="pin-btn pin-right w-7 h-7 rounded flex items-center justify-center <%= column[:pinned] == :right ? 'bg-indigo-100 dark:bg-indigo-900/40 text-indigo-600 dark:text-indigo-400' : 'text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-gray-600 dark:hover:text-gray-300' %> transition-colors">
64
- <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
65
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
64
+ <svg class="w-4 h-4 rotate-45" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" viewBox="0 0 24 24">
65
+ <path d="M12 17v5"/><path d="M9 10.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24V16a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V7a1 1 0 0 1 1-1 2 2 0 0 0 0-4H8a2 2 0 0 0 0 4 1 1 0 0 1 1 1z"/>
66
66
  </svg>
67
67
  </button>
68
68
  </div>
@@ -0,0 +1,8 @@
1
+ <div class="border-t border-gray-100 dark:border-gray-800 bg-gradient-to-b from-gray-50/60 to-white/60 px-6 py-8 text-center">
2
+ <div class="inline-flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400">
3
+ <svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
4
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
5
+ </svg>
6
+ <span class="font-medium"><%= t('infotable.table.loading.all_loaded') %></span>
7
+ </div>
8
+ </div>
@@ -6,14 +6,14 @@
6
6
  end %>
7
7
  <div class="flex flex-col h-full">
8
8
  <%# Drawer Content - Scrollable %>
9
- <div class="flex-1 overflow-y-auto px-4 py-4 bg-gray-50 dark:bg-gray-950">
9
+ <div class="flex-1 overflow-y-auto px-5 py-1 bg-white dark:bg-gray-900">
10
10
  <form id="filter-form-<%= table_name %>"
11
11
  action="<%= infotable_table_data_path(table_name: table_name) %>"
12
12
  method="get"
13
13
  data-table-frame="<%= table_name %>_content"
14
14
  data-action="submit->infotable--drawer#submitFilters"
15
15
  data-autofilter="<%= autofilter.to_s %>"
16
- class="space-y-4">
16
+ class="divide-y divide-gray-100 dark:divide-gray-800">
17
17
 
18
18
  <%# Preserve sort and per_page params %>
19
19
  <% if params[:sort].is_a?(String) && params[:sort].present? %>
@@ -29,8 +29,8 @@
29
29
 
30
30
  <%# Render filter for each filterable column %>
31
31
  <% table_class.filterable_columns.each do |column| %>
32
- <div class="group p-4 bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 shadow-sm hover:border-indigo-300 dark:hover:border-indigo-700 transition-all duration-150">
33
- <label class="block text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400 mb-2">
32
+ <div class="py-4">
33
+ <label class="block text-[13px] font-medium text-gray-700 dark:text-gray-300 mb-2">
34
34
  <%= column[:label] %>
35
35
  </label>
36
36
 
@@ -45,12 +45,12 @@
45
45
  </div>
46
46
 
47
47
  <%# Drawer Footer with actions %>
48
- <div class="px-4 py-3 border-t border-gray-100 dark:border-gray-800 bg-white dark:bg-gray-900">
48
+ <div class="px-5 py-3 border-t border-gray-100 dark:border-gray-800 bg-white dark:bg-gray-900">
49
49
  <div class="flex gap-2">
50
50
  <button type="button"
51
51
  data-action="click->infotable--drawer#clearFilters"
52
52
  data-table-name="<%= table_name %>"
53
- class="<%= autofilter ? 'w-full' : 'flex-1' %> px-4 py-2 border border-gray-200 dark:border-gray-700 rounded-lg text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-900 hover:bg-gray-50 dark:hover:bg-gray-800 hover:border-gray-300 focus:outline-none focus:ring-2 focus:ring-offset-1 focus:ring-indigo-500 transition-all">
53
+ class="<%= autofilter ? 'w-full' : 'flex-1' %> px-4 py-2 rounded-lg text-sm font-medium text-gray-600 dark:text-gray-300 bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-offset-1 focus:ring-indigo-500 transition-all">
54
54
  <%= t('infotable.filters.actions.clear_all') %>
55
55
  </button>
56
56
  <% unless autofilter %>
@@ -27,13 +27,5 @@
27
27
  </div>
28
28
  <% end %>
29
29
  <% else %>
30
- <%# No more pages - show end indicator %>
31
- <div class="border-t border-gray-100 dark:border-gray-800 bg-gradient-to-b from-gray-50/60 to-white/60 px-6 py-8 text-center">
32
- <div class="inline-flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400">
33
- <svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
34
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
35
- </svg>
36
- <span class="font-medium"><%= t('infotable.table.loading.all_loaded') %></span>
37
- </div>
38
- </div>
30
+ <%= render partial: "infotable/tables/end_indicator" %>
39
31
  <% end %>
@@ -3,14 +3,11 @@
3
3
  <%# Results info and per-page selector %>
4
4
  <div class="flex-1 flex items-center gap-4">
5
5
  <p class="text-sm text-gray-700 dark:text-gray-300">
6
- <%= t('infotable.pagination.showing') %>
7
- <span class="font-semibold text-gray-900 dark:text-gray-100"><%= pagy.from %></span>
8
- <%= t('infotable.pagination.to') %>
9
- <span class="font-semibold text-gray-900 dark:text-gray-100"><%= pagy.to %></span>
6
+ <% bold = ->(value) { tag.span(value, class: "font-semibold text-gray-900 dark:text-gray-100") } %>
10
7
  <% if pagy.count %>
11
- <%= t('infotable.pagination.of') %>
12
- <span class="font-semibold text-gray-900 dark:text-gray-100"><%= pagy.count %></span>
13
- <%= t('infotable.pagination.results') %>
8
+ <%= t('infotable.pagination.summary_html', from: bold.(pagy.from), to: bold.(pagy.to), total: bold.(pagy.count)) %>
9
+ <% else %>
10
+ <%= t('infotable.pagination.summary_no_total_html', from: bold.(pagy.from), to: bold.(pagy.to)) %>
14
11
  <% end %>
15
12
  </p>
16
13
 
@@ -143,7 +143,7 @@
143
143
 
144
144
  <%# Side panel drawer - always available for column visibility, filters if available %>
145
145
  <%# Drawer - positioned within table bounds, starts completely hidden off-screen %>
146
- <div <%= tag.attributes(infotable_drawer_attributes(config)) %>>
146
+ <div <%= tag.attributes(infotable_drawer_attributes(config, rail: rail)) %>>
147
147
  <div data-infotable--drawer-target="content" class="h-full flex flex-col overflow-hidden">
148
148
  <%= render "infotable/tables/side_panel", table_name: table_name, table_class: table_class, config: config %>
149
149
  </div>
@@ -14,8 +14,15 @@
14
14
  else "text-sm" # default
15
15
  end
16
16
  %>
17
- <tr class="infotable-row relative border-t border-gray-50 dark:border-gray-800 first:border-t-0 hover:shadow-sm hover:translate-x-px transition-all duration-200 ease-out <%= 'cursor-pointer' if row_link_enabled %>"
18
- <%= tag.attributes(data: { row_link: row_url, action: "click->infotable--table#handleRowClick" }) if row_link_enabled && row_url %>>
17
+ <%
18
+ clickable = table_class.row_clickable?
19
+ row_dom_id = dom_id(record) if record.respond_to?(:to_key) && record.to_key.present?
20
+ row_data = table_class.row_data_for(record).transform_keys { |key| key.to_s.dasherize }
21
+ row_data["row-link"] = row_url if row_url
22
+ row_data["action"] = "click->infotable--table#handleRowClick" if clickable
23
+ %>
24
+ <tr <%= tag.attributes(id: row_dom_id, data: row_data) %>
25
+ class="infotable-row group relative border-t border-gray-100 dark:border-gray-800 first:border-t-0 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors duration-150 <%= 'cursor-pointer' if clickable %> <%= table_class.row_classes_for(record) %>">
19
26
  <% if table_class.checkbox_enabled? %>
20
27
  <td class="<%= cell_padding %> w-10">
21
28
  <input type="checkbox"
@@ -2,7 +2,7 @@
2
2
  <div class="relative">
3
3
  <select name="filters[<%= column[:name] %>][value]"
4
4
  id="<%= infotable_filter_id(table_name, column, 'value') %>"
5
- class="block w-full appearance-none rounded-lg border-0 bg-white dark:bg-gray-900 pr-10 pl-3 py-2 text-base sm:text-sm text-neutral-900 dark:text-neutral-100 shadow-sm focus:outline-none focus:ring-2 focus:ring-neutral-600 focus:ring-offset-0 transition-all [box-shadow:inset_0_0_0_1px_rgb(212_212_212)] dark:[box-shadow:inset_0_0_0_1px_rgb(82_82_82)] focus:[box-shadow:inset_0_0_0_2px_rgb(82_82_82)] dark:focus:[box-shadow:inset_0_0_0_2px_rgb(163_163_163)] cursor-pointer disabled:cursor-not-allowed disabled:bg-neutral-100 disabled:opacity-75 bg-[url('data:image/svg+xml,%3csvg%20xmlns=%27http://www.w3.org/2000/svg%27%20fill=%27none%27%20viewBox=%270%200%2020%2020%27%3e%3cpath%20stroke=%27%23737373%27%20stroke-linecap=%27round%27%20stroke-linejoin=%27round%27%20stroke-width=%271.5%27%20d=%27M6%208l4%204%204-4%27/%3e%3c/svg%3e')] bg-[right_0.75rem_center] bg-no-repeat bg-[length:1.25em_1.25em]">
5
+ class="block w-full appearance-none rounded-lg border-0 bg-gray-50 dark:bg-gray-800/60 hover:bg-gray-100 dark:hover:bg-gray-800 pr-10 pl-3 py-2 text-base sm:text-sm text-neutral-900 dark:text-neutral-100 ring-1 ring-inset ring-gray-200 dark:ring-gray-700 focus:outline-none focus:bg-white dark:focus:bg-gray-900 focus:ring-2 focus:ring-indigo-500 transition-colors cursor-pointer disabled:cursor-not-allowed disabled:bg-gray-100 disabled:opacity-75 bg-[url('data:image/svg+xml,%3csvg%20xmlns=%27http://www.w3.org/2000/svg%27%20fill=%27none%27%20viewBox=%270%200%2020%2020%27%3e%3cpath%20stroke=%27%23737373%27%20stroke-linecap=%27round%27%20stroke-linejoin=%27round%27%20stroke-width=%271.5%27%20d=%27M6%208l4%204%204-4%27/%3e%3c/svg%3e')] bg-[right_0.75rem_center] bg-no-repeat bg-[length:1.25em_1.25em]">
6
6
  <option value=""><%= t('infotable.filters.all') %></option>
7
7
  <option value="true"><%= t('infotable.filters.boolean.true') %></option>
8
8
  <option value="false"><%= t('infotable.filters.boolean.false') %></option>
@@ -8,7 +8,7 @@
8
8
  kind = kind.to_sym
9
9
  input_type = kind == :date ? "date" : "number"
10
10
  ops = kind == :date ? %w[on before after] : %w[eq neq gt gte lt lte]
11
- input_class = "block w-full rounded-lg border-0 bg-white dark:bg-gray-900 pr-3 pl-3 py-2 text-base sm:text-sm text-neutral-900 dark:text-neutral-100 placeholder:text-neutral-500 shadow-sm focus:outline-none focus:ring-2 focus:ring-neutral-600 focus:ring-offset-0 transition-all [box-shadow:inset_0_0_0_1px_rgb(212_212_212)] dark:[box-shadow:inset_0_0_0_1px_rgb(82_82_82)] focus:[box-shadow:inset_0_0_0_2px_rgb(82_82_82)] dark:focus:[box-shadow:inset_0_0_0_2px_rgb(163_163_163)] disabled:cursor-not-allowed disabled:bg-neutral-100"
11
+ input_class = "block w-full rounded-lg border-0 bg-gray-50 dark:bg-gray-800/60 hover:bg-gray-100 dark:hover:bg-gray-800 pr-3 pl-3 py-2 text-base sm:text-sm text-neutral-900 dark:text-neutral-100 placeholder:text-neutral-500 ring-1 ring-inset ring-gray-200 dark:ring-gray-700 focus:outline-none focus:bg-white dark:focus:bg-gray-900 focus:ring-2 focus:ring-indigo-500 transition-colors disabled:cursor-not-allowed disabled:bg-gray-100"
12
12
  select_class = "#{input_class} appearance-none pr-10 cursor-pointer bg-[url('data:image/svg+xml,%3csvg%20xmlns=%27http://www.w3.org/2000/svg%27%20fill=%27none%27%20viewBox=%270%200%2020%2020%27%3e%3cpath%20stroke=%27%23737373%27%20stroke-linecap=%27round%27%20stroke-linejoin=%27round%27%20stroke-width=%271.5%27%20d=%27M6%208l4%204%204-4%27/%3e%3c/svg%3e')] bg-[right_0.75rem_center] bg-no-repeat bg-[length:1.25em_1.25em]"
13
13
  step = kind == :number ? "any" : nil
14
14
  %>
@@ -14,7 +14,7 @@
14
14
  id="<%= infotable_filter_id(table_name, column, 'value') %>"
15
15
  multiple
16
16
  size="6"
17
- class="block w-full appearance-none rounded-lg border-0 bg-white dark:bg-gray-900 pl-3 py-2 pr-8 text-base sm:text-sm text-neutral-900 dark:text-neutral-100 shadow-sm focus:outline-none focus:ring-2 focus:ring-neutral-600 focus:ring-offset-0 transition-all [box-shadow:inset_0_0_0_1px_rgb(212_212_212)] dark:[box-shadow:inset_0_0_0_1px_rgb(82_82_82)] focus:[box-shadow:inset_0_0_0_2px_rgb(82_82_82)] dark:focus:[box-shadow:inset_0_0_0_2px_rgb(163_163_163)] cursor-pointer disabled:cursor-not-allowed disabled:bg-neutral-100 disabled:opacity-75"
17
+ class="block w-full appearance-none rounded-lg border-0 bg-gray-50 dark:bg-gray-800/60 hover:bg-gray-100 dark:hover:bg-gray-800 pl-3 py-2 pr-8 text-base sm:text-sm text-neutral-900 dark:text-neutral-100 ring-1 ring-inset ring-gray-200 dark:ring-gray-700 focus:outline-none focus:bg-white dark:focus:bg-gray-900 focus:ring-2 focus:ring-indigo-500 transition-colors cursor-pointer disabled:cursor-not-allowed disabled:bg-gray-100 disabled:opacity-75"
18
18
  style="min-height: 120px;">
19
19
  <% options.each do |option| %>
20
20
  <% if option.is_a?(Array) %>
@@ -41,7 +41,7 @@
41
41
  <div class="relative">
42
42
  <select name="filters[<%= column[:name] %>][value]"
43
43
  id="<%= infotable_filter_id(table_name, column, 'value') %>"
44
- class="block w-full appearance-none rounded-lg border-0 bg-white dark:bg-gray-900 pr-10 pl-3 py-2 text-base sm:text-sm text-neutral-900 dark:text-neutral-100 shadow-sm focus:outline-none focus:ring-2 focus:ring-neutral-600 focus:ring-offset-0 transition-all [box-shadow:inset_0_0_0_1px_rgb(212_212_212)] dark:[box-shadow:inset_0_0_0_1px_rgb(82_82_82)] focus:[box-shadow:inset_0_0_0_2px_rgb(82_82_82)] dark:focus:[box-shadow:inset_0_0_0_2px_rgb(163_163_163)] cursor-pointer disabled:cursor-not-allowed disabled:bg-neutral-100 disabled:opacity-75 bg-[url('data:image/svg+xml,%3csvg%20xmlns=%27http://www.w3.org/2000/svg%27%20fill=%27none%27%20viewBox=%270%200%2020%2020%27%3e%3cpath%20stroke=%27%23737373%27%20stroke-linecap=%27round%27%20stroke-linejoin=%27round%27%20stroke-width=%271.5%27%20d=%27M6%208l4%204%204-4%27/%3e%3c/svg%3e')] bg-[right_0.75rem_center] bg-no-repeat bg-[length:1.25em_1.25em]">
44
+ class="block w-full appearance-none rounded-lg border-0 bg-gray-50 dark:bg-gray-800/60 hover:bg-gray-100 dark:hover:bg-gray-800 pr-10 pl-3 py-2 text-base sm:text-sm text-neutral-900 dark:text-neutral-100 ring-1 ring-inset ring-gray-200 dark:ring-gray-700 focus:outline-none focus:bg-white dark:focus:bg-gray-900 focus:ring-2 focus:ring-indigo-500 transition-colors cursor-pointer disabled:cursor-not-allowed disabled:bg-gray-100 disabled:opacity-75 bg-[url('data:image/svg+xml,%3csvg%20xmlns=%27http://www.w3.org/2000/svg%27%20fill=%27none%27%20viewBox=%270%200%2020%2020%27%3e%3cpath%20stroke=%27%23737373%27%20stroke-linecap=%27round%27%20stroke-linejoin=%27round%27%20stroke-width=%271.5%27%20d=%27M6%208l4%204%204-4%27/%3e%3c/svg%3e')] bg-[right_0.75rem_center] bg-no-repeat bg-[length:1.25em_1.25em]">
45
45
  <option value=""><%= t('infotable.filters.all') %></option>
46
46
  <% options.each do |option| %>
47
47
  <% if option.is_a?(Array) %>
@@ -6,7 +6,7 @@
6
6
  data-filter-op
7
7
  data-action="change->infotable--drawer#syncFilterOps"
8
8
  id="<%= infotable_filter_id(table_name, column, 'op') %>"
9
- class="block w-full appearance-none rounded-lg border-0 bg-white dark:bg-gray-900 pr-10 pl-3 py-2 text-base sm:text-sm text-neutral-900 dark:text-neutral-100 shadow-sm focus:outline-none focus:ring-2 focus:ring-neutral-600 focus:ring-offset-0 transition-all [box-shadow:inset_0_0_0_1px_rgb(212_212_212)] dark:[box-shadow:inset_0_0_0_1px_rgb(82_82_82)] focus:[box-shadow:inset_0_0_0_2px_rgb(82_82_82)] dark:focus:[box-shadow:inset_0_0_0_2px_rgb(163_163_163)] cursor-pointer disabled:cursor-not-allowed disabled:bg-neutral-100 disabled:opacity-75 bg-[url('data:image/svg+xml,%3csvg%20xmlns=%27http://www.w3.org/2000/svg%27%20fill=%27none%27%20viewBox=%270%200%2020%2020%27%3e%3cpath%20stroke=%27%23737373%27%20stroke-linecap=%27round%27%20stroke-linejoin=%27round%27%20stroke-width=%271.5%27%20d=%27M6%208l4%204%204-4%27/%3e%3c/svg%3e')] bg-[right_0.75rem_center] bg-no-repeat bg-[length:1.25em_1.25em]">
9
+ class="block w-full appearance-none rounded-lg border-0 bg-gray-50 dark:bg-gray-800/60 hover:bg-gray-100 dark:hover:bg-gray-800 pr-10 pl-3 py-2 text-base sm:text-sm text-neutral-900 dark:text-neutral-100 ring-1 ring-inset ring-gray-200 dark:ring-gray-700 focus:outline-none focus:bg-white dark:focus:bg-gray-900 focus:ring-2 focus:ring-indigo-500 transition-colors cursor-pointer disabled:cursor-not-allowed disabled:bg-gray-100 disabled:opacity-75 bg-[url('data:image/svg+xml,%3csvg%20xmlns=%27http://www.w3.org/2000/svg%27%20fill=%27none%27%20viewBox=%270%200%2020%2020%27%3e%3cpath%20stroke=%27%23737373%27%20stroke-linecap=%27round%27%20stroke-linejoin=%27round%27%20stroke-width=%271.5%27%20d=%27M6%208l4%204%204-4%27/%3e%3c/svg%3e')] bg-[right_0.75rem_center] bg-no-repeat bg-[length:1.25em_1.25em]">
10
10
  <option value="contains"><%= t('infotable.filters.text_ops.contains') %></option>
11
11
  <option value="exact"><%= t('infotable.filters.text_ops.exact') %></option>
12
12
  <option value="not_contain"><%= t('infotable.filters.text_ops.not_contain') %></option>
@@ -23,7 +23,7 @@
23
23
  name="filters[<%= column[:name] %>][value]"
24
24
  id="<%= infotable_filter_id(table_name, column, 'value') %>"
25
25
  placeholder="<%= t('infotable.filters.enter_value') %>"
26
- class="block w-full rounded-lg border-0 bg-white dark:bg-gray-900 pr-8 pl-3 py-2 text-base sm:text-sm text-neutral-900 dark:text-neutral-100 placeholder:text-neutral-500 shadow-sm focus:outline-none focus:ring-2 focus:ring-neutral-600 focus:ring-offset-0 transition-all [box-shadow:inset_0_0_0_1px_rgb(212_212_212)] dark:[box-shadow:inset_0_0_0_1px_rgb(82_82_82)] focus:[box-shadow:inset_0_0_0_2px_rgb(82_82_82)] dark:focus:[box-shadow:inset_0_0_0_2px_rgb(163_163_163)] disabled:cursor-not-allowed disabled:bg-neutral-100">
26
+ class="block w-full rounded-lg border-0 bg-gray-50 dark:bg-gray-800/60 hover:bg-gray-100 dark:hover:bg-gray-800 pr-8 pl-3 py-2 text-base sm:text-sm text-neutral-900 dark:text-neutral-100 placeholder:text-neutral-500 ring-1 ring-inset ring-gray-200 dark:ring-gray-700 focus:outline-none focus:bg-white dark:focus:bg-gray-900 focus:ring-2 focus:ring-indigo-500 transition-colors disabled:cursor-not-allowed disabled:bg-gray-100">
27
27
  <button type="button"
28
28
  class="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
29
29
  data-action="click->infotable--drawer#clearSingleFilter"
@@ -35,7 +35,7 @@
35
35
  data-default-visible="<%= column[:visible] %>"
36
36
  <%= tag.attributes(data: { default_pinned: column[:pinned] }) if column[:pinned] %>
37
37
  style="<%= infotable_cell_width_style(column) %>"
38
- class="<%= header_padding %> text-left text-xs font-semibold text-gray-900 dark:text-gray-100 tracking-wider select-none bg-gray-50 dark:bg-gray-800 sticky top-0 z-20">
38
+ class="<%= header_padding %> text-left whitespace-nowrap text-xs font-medium tracking-wide text-gray-500 dark:text-gray-400 select-none bg-gray-50 dark:bg-gray-800 sticky top-0 z-20">
39
39
  <% if column[:sortable] %>
40
40
  <% sort_index = current_sorts.index { |name, _| name == column[:name].to_s } %>
41
41
  <% sort_dir = sort_index ? current_sorts[sort_index].last : nil %>
@@ -90,7 +90,7 @@
90
90
  </table>
91
91
 
92
92
  <%# Next page frame for infinite scroll - rendered after table %>
93
- <% if @infinite_scroll_enabled && params[:page].blank? %>
93
+ <% if @infinite_scroll_enabled %>
94
94
  <%= render partial: "infotable/tables/next_page_frame" %>
95
95
  <% end %>
96
96
  <% else %>
data/config/importmap.rb CHANGED
@@ -2,5 +2,5 @@
2
2
  pin "infotable", to: "infotable/index.js"
3
3
  pin_all_from Infotable::Engine.root.join("app/assets/javascripts/infotable/controllers"), under: "controllers/infotable", to: "infotable/controllers"
4
4
 
5
- # External dependencies
6
- pin "sortablejs", to: "https://cdn.jsdelivr.net/npm/sortablejs@1.15.7/modular/sortable.esm.js"
5
+ # External dependencies, served with the gem (no third-party CDN at runtime)
6
+ pin "sortablejs", to: "infotable/vendor/sortable.esm.js"
@@ -0,0 +1,126 @@
1
+ de:
2
+ infotable:
3
+ table:
4
+ buttons:
5
+ export_csv: "CSV exportieren"
6
+ filters: "Filter"
7
+ exporting: "Exportiere..."
8
+ refresh: "Aktualisieren"
9
+ columns: "Spalten"
10
+ sort_hint: "Klicken zum Sortieren, Shift+Klick fügt eine Spalte zur Sortierung hinzu"
11
+ loading:
12
+ initial: "Daten werden geladen..."
13
+ more_items: "Weitere Einträge werden geladen..."
14
+ all_loaded: "Alle Einträge geladen"
15
+ resize_column: "Spaltenbreite ändern"
16
+ empty_state:
17
+ title: "Keine Daten gefunden"
18
+ description: "Fügen Sie Datensätze hinzu, um sie hier zu sehen."
19
+ filtered_title: "Keine Zeilen entsprechen Ihren Filtern"
20
+ filtered_description: "Entfernen Sie einen Filter oder suchen Sie nach etwas anderem."
21
+
22
+ drawer:
23
+ label: "Filter und Spalten"
24
+ panel_title:
25
+ customize_columns: "Spalten anpassen"
26
+ filters: "Filter"
27
+ columns: "Spalten"
28
+ tabs:
29
+ filters: "Filter"
30
+ columns: "Spalten"
31
+
32
+ columns:
33
+ instructions: "Zum Umsortieren ziehen, Spalten anheften und ein-/ausblenden:"
34
+ pin:
35
+ left: "Links anheften"
36
+ right: "Rechts anheften"
37
+ actions:
38
+ select_all: "Alle auswählen"
39
+ deselect_all: "Alle abwählen"
40
+ reset_to_defaults: "🔄 Standard wiederherstellen"
41
+
42
+ filters:
43
+ actions:
44
+ clear_all: "Alle löschen"
45
+ apply_filters: "Filter anwenden"
46
+ clear_filter: "Diesen Filter löschen"
47
+ active:
48
+ search: "Suche: „%{query}“"
49
+ remove: "Filter %{filter} entfernen"
50
+ clear_all: "Alle Filter löschen"
51
+ from: "Von"
52
+ to: "Bis"
53
+ min: "Min"
54
+ max: "Max"
55
+ enter_value: "Wert eingeben..."
56
+ all: "Alle"
57
+ multi_select_hint: "Strg/Cmd gedrückt halten, um mehrere auszuwählen"
58
+ options_truncated: "Die ersten %{shown} von %{total} Optionen werden angezeigt"
59
+ boolean:
60
+ "true": "Wahr"
61
+ "false": "Falsch"
62
+ empty: "Leer"
63
+ not_empty: "Nicht leer"
64
+ text_ops:
65
+ contains: "Enthält"
66
+ exact: "Exakte Übereinstimmung"
67
+ not_contain: "Enthält nicht"
68
+ starts_with: "Beginnt mit"
69
+ ends_with: "Endet mit"
70
+ is_empty: "Ist leer"
71
+ is_not_empty: "Ist nicht leer"
72
+ range_ops:
73
+ between: "Zwischen"
74
+ number_ops:
75
+ eq: "Gleich"
76
+ neq: "Ungleich"
77
+ gt: "Größer als"
78
+ gte: "Größer oder gleich"
79
+ lt: "Kleiner als"
80
+ lte: "Kleiner oder gleich"
81
+ date_ops:
82
+ "on": "Am"
83
+ before: "Vor"
84
+ after: "Nach"
85
+
86
+ formatters:
87
+ ago: "vor %{time}"
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: "Aktion"
94
+ boolean:
95
+ "true": "Ja"
96
+ "false": "Nein"
97
+ duration:
98
+ short:
99
+ d: "%{count} T"
100
+ h: "%{count} Std."
101
+ m: "%{count} Min."
102
+ s: "%{count} Sek."
103
+ long:
104
+ d: { one: "%{count} Tag", few: "%{count} Tage", many: "%{count} Tage", other: "%{count} Tage" }
105
+ h: { one: "%{count} Stunde", few: "%{count} Stunden", many: "%{count} Stunden", other: "%{count} Stunden" }
106
+ m: { one: "%{count} Minute", few: "%{count} Minuten", many: "%{count} Minuten", other: "%{count} Minuten" }
107
+ s: { one: "%{count} Sekunde", few: "%{count} Sekunden", many: "%{count} Sekunden", other: "%{count} Sekunden" }
108
+
109
+ batch:
110
+ selected: "%{count} ausgewählt"
111
+ clear: "Auswahl aufheben"
112
+ select_all: "Alle Zeilen auswählen"
113
+ select_row: "Zeile auswählen"
114
+ select_all_matching: "Alle %{count} Treffer auswählen"
115
+ truncated: "Nur die ersten %{max} Zeilen sind ausgewählt"
116
+
117
+ search:
118
+ placeholder: "Suchen..."
119
+ label: "Tabelle durchsuchen"
120
+
121
+ pagination:
122
+ summary_html: "Zeige %{from} bis %{to} von %{total} Ergebnissen"
123
+ summary_no_total_html: "Zeige %{from} bis %{to}"
124
+ rows_per_page: "Zeilen pro Seite:"
125
+ previous: "Zurück"
126
+ next: "Weiter"