infotable 1.4.0 → 1.5.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.
@@ -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
@@ -28,6 +28,17 @@ module Infotable
28
28
  render partial: "infotable/tables/table", locals: locals
29
29
  end
30
30
 
31
+ # Inline width style for a header/body cell. Data columns are clipped to their width (ellipsis);
32
+ # action columns hold buttons, so their width is only a minimum and the column grows to fit all
33
+ # of them (a clipped "Delete" button is worse than a wider column).
34
+ def infotable_cell_width_style(column, ellipsis: false)
35
+ if %w[action actions].include?(column[:type].to_s)
36
+ "width: 1%; min-width: #{column[:width]}; white-space: nowrap;"
37
+ else
38
+ "width: #{column[:width]}; max-width: #{column[:width]}; overflow: hidden;#{' text-overflow: ellipsis;' if ellipsis}"
39
+ end
40
+ end
41
+
31
42
  # Format cell value based on column definition
32
43
  def format_cell_value(record, column, table_class = nil)
33
44
  value = (table_class || Infotable::Base).cell_value(record, column)
@@ -78,29 +89,6 @@ module Infotable
78
89
  end
79
90
  end
80
91
 
81
- # Build export params with current filters, sort, and visible columns
82
- def build_export_params(table_name, table_class)
83
- params_hash = {
84
- table_name: table_name,
85
- sort_column: scalar_request_param(:sort_column),
86
- sort_direction: scalar_request_param(:sort_direction),
87
- sort: scalar_request_param(:sort)
88
- }
89
-
90
- # Add filters if present
91
- if params[:filters].present?
92
- params_hash[:filters] = params[:filters]
93
- end
94
-
95
- # Get visible columns from localStorage via JavaScript or use defaults
96
- # For now, we'll pass all visible columns and let the controller handle it
97
- # The JavaScript will need to update the link dynamically
98
- visible_column_names = table_class.visible_columns.map { |c| c[:name].to_s }
99
- params_hash[:visible_columns] = visible_column_names.join(",")
100
-
101
- params_hash.compact
102
- end
103
-
104
92
  private
105
93
 
106
94
  def resolve_table_class(table_name)
@@ -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>
@@ -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 %>
@@ -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"
@@ -29,7 +36,7 @@
29
36
  <% table_class.columns.each do |column| %>
30
37
  <td data-column-name="<%= column[:name] %>"
31
38
  data-default-visible="<%= column[:visible] %>"
32
- style="width: <%= column[:width] %>; max-width: <%= column[:width] %>; overflow: hidden; text-overflow: ellipsis;"
39
+ style="<%= infotable_cell_width_style(column, ellipsis: true) %>"
33
40
  class="<%= cell_padding %> whitespace-nowrap <%= text_size %> text-gray-700 dark:text-gray-300">
34
41
  <%= format_cell_value(record, column, table_class) %>
35
42
  </td>
@@ -34,8 +34,8 @@
34
34
  data-column-name="<%= column[:name] %>"
35
35
  data-default-visible="<%= column[:visible] %>"
36
36
  <%= tag.attributes(data: { default_pinned: column[:pinned] }) if column[:pinned] %>
37
- style="width: <%= column[:width] %>; max-width: <%= column[:width] %>; overflow: hidden;"
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">
37
+ style="<%= infotable_cell_width_style(column) %>"
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"
@@ -14,9 +14,11 @@ Infotable.configure do |config|
14
14
  # Drawer width
15
15
  config.drawer_width = "26rem" # px, rem, em, vw or %
16
16
 
17
- # Controller the engine controllers inherit from. Use your app's controller so
18
- # authentication, locale, tenant scoping etc. also protect the tables:
19
- # config.parent_controller = "ApplicationController"
17
+ # SECURITY: tables are served at /infotable/tables/:name/{data,export,ids}. The engine
18
+ # controllers inherit from this controller, so authentication, locale, tenant scoping etc.
19
+ # of your app also protect the tables. With the default (ActionController::Base) nothing
20
+ # does: anyone can read and export every table that has no `authorize { ... }` block.
21
+ config.parent_controller = "ApplicationController"
20
22
 
21
23
  # Mirror search, filters, sort and page in the address bar (shareable links, Back button)
22
24
  # config.url_state = true
@@ -1,7 +1,7 @@
1
1
  module Infotable
2
2
  class Base
3
3
  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
4
+ 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
5
 
6
6
  def inherited(subclass)
7
7
  super
@@ -19,6 +19,7 @@ module Infotable
19
19
  subclass.infinite_scroll_settings = {
20
20
  enabled: false
21
21
  }
22
+ subclass.row_attr_settings = { class: nil, data: nil }
22
23
  subclass.row_link_settings = {
23
24
  enabled: false,
24
25
  url: nil
@@ -65,7 +66,9 @@ module Infotable
65
66
  subclass.countless_setting = parent.countless_setting
66
67
  subclass.url_state_setting = parent.url_state_setting
67
68
  subclass.resizable_columns_setting = parent.resizable_columns_setting
68
- %i[pagination_settings sort_settings height_settings infinite_scroll_settings row_link_settings
69
+ subclass.instance_variable_set(:@toolbar_setting, parent.instance_variable_get(:@toolbar_setting))
70
+ subclass.instance_variable_set(:@export_column_names, parent.instance_variable_get(:@export_column_names)&.dup)
71
+ %i[pagination_settings sort_settings height_settings infinite_scroll_settings row_link_settings row_attr_settings
69
72
  slim_rows_settings row_size_settings filters_button_settings display_settings
70
73
  nil_value_settings empty_value_settings].each do |attr|
71
74
  subclass.public_send("#{attr}=", parent.public_send(attr).dup)
@@ -134,8 +137,9 @@ module Infotable
134
137
  # Plucks when the id method is a plain column of an ungrouped query, loads records otherwise.
135
138
  def checkbox_ids(query, limit = nil)
136
139
  method = @checkbox_settings[:id_method]
137
- scope = query.reorder(nil)
138
- scope = scope.limit(limit) if limit
140
+ # Without a cap the order is irrelevant (and costs a sort); with one, the table's own order
141
+ # decides which rows make it in, so a capped selection is the first N rows the user sees
142
+ scope = limit ? query.limit(limit) : query.reorder(nil)
139
143
  model = query.model
140
144
 
141
145
  if model.column_names.include?(method.to_s) && scope.group_values.empty? && scope.having_clause.empty?
@@ -167,8 +171,16 @@ module Infotable
167
171
  end
168
172
 
169
173
  # 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
+
170
178
  def column(name, **options)
171
179
  @table_columns ||= []
180
+ unknown = options.keys - COLUMN_OPTIONS
181
+ if unknown.any? && defined?(Rails) && Rails.logger
182
+ Rails.logger.warn("[Infotable] #{self.name || 'table'}: column :#{name} ignores unknown option(s) #{unknown.map(&:inspect).join(', ')}")
183
+ end
172
184
  type = options[:type] || :string
173
185
 
174
186
  # Determine width - use custom if provided and not blank, otherwise use default
@@ -319,6 +331,51 @@ module Infotable
319
331
  end
320
332
  end
321
333
 
334
+ # Extra CSS classes per row: a String, or a Proc that receives the record (nil/false adds nothing).
335
+ # Tailwind only generates classes it can see in your source, so write them out in full.
336
+ # @example
337
+ # row_class ->(order) { "bg-red-50" if order.overdue? }
338
+ def row_class(value = nil, &block)
339
+ @row_attr_settings[:class] = value || block
340
+ end
341
+
342
+ def row_classes_for(record)
343
+ setting = @row_attr_settings[:class]
344
+ (setting.respond_to?(:call) ? setting.call(record) : setting).presence
345
+ end
346
+
347
+ # Extra data-* attributes per row: a Proc that receives the record and returns a Hash
348
+ # ({ post_id: 1 } becomes data-post-id="1"). Handy for your own Stimulus controllers.
349
+ # @example
350
+ # row_data ->(post) { { post_id: post.id, status: post.status } }
351
+ def row_data(value = nil, &block)
352
+ @row_attr_settings[:data] = value || block
353
+ end
354
+
355
+ def row_data_for(record)
356
+ setting = @row_attr_settings[:data]
357
+ data = setting.respond_to?(:call) ? setting.call(record) : setting
358
+ data.is_a?(Hash) ? data : {}
359
+ end
360
+
361
+ # Clicking a row (not a link, button or input inside it) fires a cancelable
362
+ # `infotable:row-click` event on the row (detail: { id, url, data }). If nothing calls
363
+ # preventDefault() and the table has a row_link, the click falls back to following it.
364
+ # @example
365
+ # row_click true
366
+ def row_click(enabled = true)
367
+ @row_link_settings[:click] = enabled ? true : false
368
+ end
369
+
370
+ def row_click?
371
+ @row_link_settings[:click] ? true : false
372
+ end
373
+
374
+ # Rows react to clicks (row_link and/or row_click)
375
+ def row_clickable?
376
+ row_link_enabled? || row_click?
377
+ end
378
+
322
379
  # Set row size (padding and text size)
323
380
  # @param size [Symbol] - Row size (:slim, :mid, :default)
324
381
  # @example
@@ -31,7 +31,7 @@ module Infotable
31
31
  @nil_value = "-" # Default placeholder for nil values
32
32
  @empty_value = "-" # Default placeholder for empty strings
33
33
  @url_state = true # mirror table state in the address bar
34
- @max_select_all = 10_000 # "select all matching" collects at most this many ids (nil = no limit)
34
+ @max_select_all = 4000 # "select all matching" collects at most this many ids (nil = no limit). Rack accepts 4096 params per request, and every id is sent as ids[]
35
35
  @max_filter_options = 500 # select filters render at most this many <option>s (nil = no limit)
36
36
  @parent_controller = "ActionController::Base" # Set to "ApplicationController" to inherit host auth/filters
37
37
  end
@@ -6,18 +6,11 @@ module Infotable
6
6
  class Engine < ::Rails::Engine
7
7
  isolate_namespace Infotable
8
8
 
9
- # Configure engine
10
- config.infotable = ActiveSupport::OrderedOptions.new
11
-
12
- # Default configuration values
13
- config.infotable.table_min_height = "400px"
14
- config.infotable.table_max_height = "600px"
15
-
16
9
  # Rails already adds the engine's app/assets/* directories to the asset path;
17
10
  # only the precompile list is needed. Skipped for apps without an asset pipeline.
18
11
  initializer "infotable.assets" do |app|
19
12
  if app.config.respond_to?(:assets)
20
- app.config.assets.precompile += %w[infotable/controllers/*.js]
13
+ app.config.assets.precompile += %w[infotable/controllers/*.js infotable/vendor/*.js]
21
14
  end
22
15
  end
23
16
 
@@ -110,7 +110,7 @@ module Infotable
110
110
  column_name = column[:name]
111
111
  is_virtual = having?(column)
112
112
  # Use qualified column reference
113
- column_ref = qualified_column_ref(column)
113
+ column_ref = text_ref(column)
114
114
 
115
115
  result = query
116
116
 
@@ -145,6 +145,14 @@ module Infotable
145
145
  result
146
146
  end
147
147
 
148
+ # PostgreSQL has no LOWER / ILIKE / = '' for numbers, dates, uuids...: a text filter on such a
149
+ # column (filter_type: :text on an integer) compares its text form.
150
+ def text_ref(column)
151
+ ref = qualified_column_ref(column)
152
+ type = select_column_type(column)
153
+ type.nil? || %i[string text].include?(type) ? ref : "CAST(#{ref} AS text)"
154
+ end
155
+
148
156
  # Boolean filter
149
157
  def apply_boolean_filter(column, params)
150
158
  return query unless params.is_a?(Hash)
@@ -245,6 +253,7 @@ module Infotable
245
253
  from = parse_date(scalar(params[:from] || params["from"]))
246
254
  to = parse_date(scalar(params[:to] || params["to"]))
247
255
  to = to.end_of_day if to && datetime
256
+ from = from.beginning_of_day if from && datetime # same zone as `to` (Time.zone), not UTC midnight
248
257
 
249
258
  result = query
250
259
  result = result.public_send(filter_method, "#{column_ref} >= ?", from) if from
@@ -10,7 +10,7 @@ module Infotable
10
10
  when :badge
11
11
  format_as_badge(value, true_text, false_text)
12
12
  when :icon
13
- format_as_icon(value)
13
+ format_as_icon(value, true_text, false_text)
14
14
  else
15
15
  value ? true_text : false_text
16
16
  end
@@ -26,11 +26,11 @@ module Infotable
26
26
  end
27
27
  end
28
28
 
29
- def format_as_icon(value)
29
+ def format_as_icon(value, true_text, false_text)
30
30
  if value
31
- html_safe('<svg class="w-5 h-5 text-green-500" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/></svg>')
31
+ html_safe('<svg class="w-5 h-5 text-green-500" fill="currentColor" viewBox="0 0 20 20" role="img" aria-label="' + ERB::Util.html_escape(true_text) + '"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/></svg>')
32
32
  else
33
- html_safe('<svg class="w-5 h-5 text-gray-400" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"/></svg>')
33
+ html_safe('<svg class="w-5 h-5 text-gray-400" fill="currentColor" viewBox="0 0 20 20" role="img" aria-label="' + ERB::Util.html_escape(false_text) + '"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"/></svg>')
34
34
  end
35
35
  end
36
36
  end
@@ -22,9 +22,18 @@ module Infotable
22
22
  raise NotFound, "Table '#{table_name}' not found. Make sure it's defined in app/infotable/"
23
23
  end
24
24
 
25
+ CANDIDATES = Concurrent::Map.new
26
+
27
+ # The class names to try are pure string work (up to 2**(n-1) of them): memoized per name
25
28
  def candidates(table_name)
26
29
  return [] unless table_name.to_s.match?(/\A[a-z0-9]+(_[a-z0-9]+)*\z/i)
27
30
 
31
+ CANDIDATES.compute_if_absent(table_name.to_s) { build_candidates(table_name) }
32
+ end
33
+
34
+ def build_candidates(table_name)
35
+ return [] unless table_name.to_s.match?(/\A[a-z0-9]+(_[a-z0-9]+)*\z/i)
36
+
28
37
  parts = table_name.to_s.sub(/_table\z/, "").split("_")
29
38
  return [] if parts.empty? || parts.size > MAX_PARTS
30
39
 
@@ -1,3 +1,3 @@
1
1
  module Infotable
2
- VERSION = "1.4.0"
2
+ VERSION = "1.5.0"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: infotable
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.4.0
4
+ version: 1.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jakub Cieślar
@@ -137,6 +137,7 @@ files:
137
137
  - app/assets/javascripts/infotable/controllers/drawer_controller.js
138
138
  - app/assets/javascripts/infotable/controllers/table_controller.js
139
139
  - app/assets/javascripts/infotable/index.js
140
+ - app/assets/javascripts/infotable/vendor/sortable.esm.js
140
141
  - app/assets/tailwind/infotable/engine.css
141
142
  - app/controllers/infotable/application_controller.rb
142
143
  - app/controllers/infotable/tables_controller.rb
@@ -147,6 +148,7 @@ files:
147
148
  - app/models/infotable/application_record.rb
148
149
  - app/views/infotable/tables/_active_filters.html.erb
149
150
  - app/views/infotable/tables/_column_visibility.html.erb
151
+ - app/views/infotable/tables/_end_indicator.html.erb
150
152
  - app/views/infotable/tables/_filter_drawer.html.erb
151
153
  - app/views/infotable/tables/_next_page_frame.html.erb
152
154
  - app/views/infotable/tables/_pagination.html.erb