infotable 1.0.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +14 -2
  3. data/app/assets/javascripts/infotable/controllers/drawer_controller.js +68 -42
  4. data/app/assets/javascripts/infotable/controllers/table_controller.js +127 -23
  5. data/app/assets/tailwind/infotable/engine.css +5 -0
  6. data/app/controllers/infotable/tables_controller.rb +17 -7
  7. data/app/helpers/infotable/tables_helper.rb +64 -2
  8. data/app/views/infotable/tables/_active_filters.html.erb +24 -0
  9. data/app/views/infotable/tables/_column_visibility.html.erb +11 -11
  10. data/app/views/infotable/tables/_filter_drawer.html.erb +4 -4
  11. data/app/views/infotable/tables/_next_page_frame.html.erb +5 -5
  12. data/app/views/infotable/tables/_pagination.html.erb +12 -12
  13. data/app/views/infotable/tables/_rail.html.erb +56 -0
  14. data/app/views/infotable/tables/_side_panel.html.erb +22 -10
  15. data/app/views/infotable/tables/_table.html.erb +43 -26
  16. data/app/views/infotable/tables/_table_rows.html.erb +3 -3
  17. data/app/views/infotable/tables/filters/_boolean.html.erb +2 -2
  18. data/app/views/infotable/tables/filters/_date.html.erb +2 -30
  19. data/app/views/infotable/tables/filters/_number.html.erb +2 -32
  20. data/app/views/infotable/tables/filters/_range.html.erb +66 -0
  21. data/app/views/infotable/tables/filters/_select.html.erb +4 -4
  22. data/app/views/infotable/tables/filters/_text.html.erb +3 -3
  23. data/app/views/infotable/tables/show.html.erb +22 -13
  24. data/config/locales/en.yml +23 -0
  25. data/config/locales/pl.yml +23 -0
  26. data/config/routes.rb +1 -0
  27. data/lib/generators/infotable/install/install_generator.rb +10 -64
  28. data/lib/infotable/base.rb +66 -0
  29. data/lib/infotable/configuration.rb +3 -1
  30. data/lib/infotable/engine.rb +0 -8
  31. data/lib/infotable/formatters/action_formatter.rb +7 -7
  32. data/lib/infotable/formatters/avatar_formatter.rb +2 -2
  33. data/lib/infotable/formatters/badge_formatter.rb +9 -9
  34. data/lib/infotable/formatters/boolean_formatter.rb +2 -2
  35. data/lib/infotable/formatters/link_formatter.rb +1 -1
  36. data/lib/infotable/formatters/percent_formatter.rb +1 -1
  37. data/lib/infotable/version.rb +1 -1
  38. data/lib/infotable.rb +0 -12
  39. metadata +5 -3
  40. data/lib/generators/infotable/install/templates/TAILWIND_CONFIG.md +0 -131
  41. data/lib/tasks/infotable.rake +0 -55
@@ -1,32 +1,2 @@
1
- <%# Number filter - from/to range %>
2
- <div class="grid grid-cols-2 gap-2">
3
- <%# From input %>
4
- <div class="relative">
5
- <input type="number"
6
- name="filters[<%= column[:name] %>][from]"
7
- id="<%= infotable_filter_id(table_name, column, 'from') %>"
8
- placeholder="<%= t('infotable.filters.min') %>"
9
- step="any"
10
- class="block w-full rounded-lg border-0 bg-white pr-8 pl-3 py-2 text-base sm:text-sm text-neutral-900 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)] focus:[box-shadow:inset_0_0_0_2px_rgb(82_82_82)] disabled:cursor-not-allowed disabled:bg-neutral-100">
11
- <button type="button"
12
- class="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 transition-colors"
13
- data-action="click->infotable--drawer#clearSingleFilter"
14
- data-filter-name="<%= column[:name] %>"
15
- data-filter-type="number"
16
- title="<%= t('infotable.filters.clear_filter') %>">
17
- <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
18
- <path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
19
- </svg>
20
- </button>
21
- </div>
22
-
23
- <%# To input %>
24
- <div class="relative">
25
- <input type="number"
26
- name="filters[<%= column[:name] %>][to]"
27
- id="<%= infotable_filter_id(table_name, column, 'to') %>"
28
- placeholder="<%= t('infotable.filters.max') %>"
29
- step="any"
30
- class="block w-full rounded-lg border-0 bg-white pr-3 pl-3 py-2 text-base sm:text-sm text-neutral-900 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)] focus:[box-shadow:inset_0_0_0_2px_rgb(82_82_82)] disabled:cursor-not-allowed disabled:bg-neutral-100">
31
- </div>
32
- </div>
1
+ <%# Number filter: range or comparison, see _range %>
2
+ <%= render "infotable/tables/filters/range", column: column, table_name: table_name, kind: :number %>
@@ -0,0 +1,66 @@
1
+ <%# Shared by the number and date filters.
2
+ Locals: column, table_name, kind (:number or :date)
3
+ The operator select picks the shape of the filter:
4
+ "" from/to inputs (either may stay blank)
5
+ single ops one :value input
6
+ empty ops no input %>
7
+ <%
8
+ kind = kind.to_sym
9
+ input_type = kind == :date ? "date" : "number"
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"
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
+ step = kind == :number ? "any" : nil
14
+ %>
15
+ <div class="space-y-2" data-range-container id="<%= infotable_filter_id(table_name, column, 'container') %>">
16
+ <div class="flex items-center gap-2">
17
+ <select name="filters[<%= column[:name] %>][op]"
18
+ data-range-op
19
+ data-action="change->infotable--drawer#syncFilterOps"
20
+ id="<%= infotable_filter_id(table_name, column, 'op') %>"
21
+ class="<%= select_class %>">
22
+ <option value=""><%= t("infotable.filters.range_ops.between") %></option>
23
+ <% ops.each do |op| %>
24
+ <option value="<%= op %>"><%= t("infotable.filters.#{kind}_ops.#{op}") %></option>
25
+ <% end %>
26
+ <option value="is_empty"><%= t("infotable.filters.text_ops.is_empty") %></option>
27
+ <option value="is_not_empty"><%= t("infotable.filters.text_ops.is_not_empty") %></option>
28
+ </select>
29
+ <button type="button"
30
+ class="shrink-0 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
31
+ data-action="click->infotable--drawer#clearSingleFilter"
32
+ data-filter-name="<%= column[:name] %>"
33
+ data-filter-type="<%= kind %>"
34
+ title="<%= t('infotable.filters.clear_filter') %>">
35
+ <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
36
+ <path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
37
+ </svg>
38
+ </button>
39
+ </div>
40
+
41
+ <div class="grid grid-cols-2 gap-2" data-range-fields="range">
42
+ <input type="<%= input_type %>"
43
+ name="filters[<%= column[:name] %>][from]"
44
+ id="<%= infotable_filter_id(table_name, column, 'from') %>"
45
+ placeholder="<%= t(kind == :date ? 'infotable.filters.from' : 'infotable.filters.min') %>"
46
+ aria-label="<%= t('infotable.filters.from') %>"
47
+ <% if step %>step="<%= step %>"<% end %>
48
+ class="<%= input_class %>">
49
+ <input type="<%= input_type %>"
50
+ name="filters[<%= column[:name] %>][to]"
51
+ id="<%= infotable_filter_id(table_name, column, 'to') %>"
52
+ placeholder="<%= t(kind == :date ? 'infotable.filters.to' : 'infotable.filters.max') %>"
53
+ aria-label="<%= t('infotable.filters.to') %>"
54
+ <% if step %>step="<%= step %>"<% end %>
55
+ class="<%= input_class %>">
56
+ </div>
57
+
58
+ <div data-range-fields="single" style="display: none">
59
+ <input type="<%= input_type %>"
60
+ name="filters[<%= column[:name] %>][value]"
61
+ id="<%= infotable_filter_id(table_name, column, 'value') %>"
62
+ placeholder="<%= t('infotable.filters.enter_value') %>"
63
+ <% if step %>step="<%= step %>"<% end %>
64
+ class="<%= input_class %>">
65
+ </div>
66
+ </div>
@@ -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 pl-3 py-2 pr-8 text-base sm:text-sm text-neutral-900 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)] focus:[box-shadow:inset_0_0_0_2px_rgb(82_82_82)] 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-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"
18
18
  style="min-height: 120px;">
19
19
  <% options.each do |option| %>
20
20
  <% if option.is_a?(Array) %>
@@ -25,7 +25,7 @@
25
25
  <% end %>
26
26
  </select>
27
27
  <button type="button"
28
- class="absolute right-2 top-2 text-gray-400 hover:text-gray-600 transition-colors"
28
+ class="absolute right-2 top-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
29
29
  data-action="click->infotable--drawer#clearSingleFilter"
30
30
  data-filter-name="<%= column[:name] %>"
31
31
  data-filter-type="select"
@@ -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 pr-10 pl-3 py-2 text-base sm:text-sm text-neutral-900 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)] focus:[box-shadow:inset_0_0_0_2px_rgb(82_82_82)] 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-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]">
45
45
  <option value=""><%= t('infotable.filters.all') %></option>
46
46
  <% options.each do |option| %>
47
47
  <% if option.is_a?(Array) %>
@@ -52,7 +52,7 @@
52
52
  <% end %>
53
53
  </select>
54
54
  <button type="button"
55
- class="absolute right-8 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 transition-colors"
55
+ class="absolute right-8 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
56
56
  data-action="click->infotable--drawer#clearSingleFilter"
57
57
  data-filter-name="<%= column[:name] %>"
58
58
  data-filter-type="select"
@@ -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 pr-10 pl-3 py-2 text-base sm:text-sm text-neutral-900 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)] focus:[box-shadow:inset_0_0_0_2px_rgb(82_82_82)] 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-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]">
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,9 +23,9 @@
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 pr-8 pl-3 py-2 text-base sm:text-sm text-neutral-900 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)] focus:[box-shadow:inset_0_0_0_2px_rgb(82_82_82)] disabled:cursor-not-allowed disabled:bg-neutral-100">
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">
27
27
  <button type="button"
28
- class="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 transition-colors"
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"
30
30
  data-filter-name="<%= column[:name] %>"
31
31
  data-filter-type="text"
@@ -1,13 +1,15 @@
1
1
  <%# This view renders inside the turbo frame %>
2
2
  <%# Turbo requires the frame element to be present in the response %>
3
3
  <turbo-frame id="<%= params[:table_name] %>_content">
4
- <div class="relative overflow-y-auto overflow-x-auto scroll-smooth [scrollbar-width:thin] [scrollbar-color:#cbd5e1_#f1f5f9] [&::-webkit-scrollbar]:w-2 [&::-webkit-scrollbar]:h-2 [&::-webkit-scrollbar-track]:bg-slate-100 [&::-webkit-scrollbar-thumb]:bg-slate-300 [&::-webkit-scrollbar-thumb]:rounded [&::-webkit-scrollbar-thumb:hover]:bg-slate-400"
4
+ <% active_filters = infotable_active_filters(@table_class, @filters, @search) %>
5
+ <%= render "infotable/tables/active_filters", chips: active_filters, table_name: params[:table_name] %>
6
+ <div<% if @pagy && !@table_class.countless? %> data-total-count="<%= @pagy.count %>"<% end %> class="relative overflow-y-auto overflow-x-auto scroll-smooth [scrollbar-width:thin] [scrollbar-color:#cbd5e1_#f1f5f9] [&::-webkit-scrollbar]:w-2 [&::-webkit-scrollbar]:h-2 [&::-webkit-scrollbar-track]:bg-slate-100 [&::-webkit-scrollbar-thumb]:bg-slate-300 [&::-webkit-scrollbar-thumb]:rounded [&::-webkit-scrollbar-thumb:hover]:bg-slate-400 dark:[scrollbar-color:#475569_#1e293b] dark:[&::-webkit-scrollbar-track]:bg-slate-800 dark:[&::-webkit-scrollbar-thumb]:bg-slate-600"
5
7
  style="min-height: <%= @table_class.min_height %>; max-height: <%= @table_class.max_height %>;">
6
8
 
7
9
  <% if @records.any? %>
8
10
  <%# Table %>
9
- <table class="min-w-full divide-y divide-gray-200">
10
- <thead class="bg-gray-50 border-b-2 border-gray-200">
11
+ <table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
12
+ <thead class="bg-gray-50 dark:bg-gray-800 border-b-2 border-gray-200 dark:border-gray-700">
11
13
  <tr>
12
14
  <%
13
15
  row_size = @table_class.row_size_value
@@ -18,12 +20,12 @@
18
20
  end
19
21
  %>
20
22
  <% if @table_class.checkbox_enabled? %>
21
- <th scope="col" class="<%= header_padding %> w-10 bg-gray-50 sticky top-0 z-20">
23
+ <th scope="col" class="<%= header_padding %> w-10 bg-gray-50 dark:bg-gray-800 sticky top-0 z-20">
22
24
  <input type="checkbox"
23
25
  aria-label="<%= t('infotable.batch.select_all') %>"
24
26
  data-infotable--table-target="selectAll"
25
27
  data-action="change->infotable--table#toggleAll"
26
- class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500">
28
+ class="h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-indigo-600 dark:text-indigo-400 focus:ring-indigo-500">
27
29
  </th>
28
30
  <% end %>
29
31
  <% @table_class.columns.each do |column| %>
@@ -32,7 +34,7 @@
32
34
  data-default-visible="<%= column[:visible] %>"
33
35
  <%= tag.attributes(data: { default_pinned: column[:pinned] }) if column[:pinned] %>
34
36
  style="width: <%= column[:width] %>; max-width: <%= column[:width] %>; overflow: hidden;"
35
- class="<%= header_padding %> text-left text-xs font-semibold text-gray-900 tracking-wider select-none bg-gray-50 sticky top-0 z-20">
37
+ 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">
36
38
  <% if column[:sortable] %>
37
39
  <% sort_index = current_sorts.index { |name, _| name == column[:name].to_s } %>
38
40
  <% sort_dir = sort_index ? current_sorts[sort_index].last : nil %>
@@ -56,16 +58,16 @@
56
58
  <span class="flex-none rounded">
57
59
  <% if sort_index %>
58
60
  <% if sort_dir == 'asc' %>
59
- <svg class="h-4 w-4 text-indigo-600" fill="currentColor" viewBox="0 0 20 20">
61
+ <svg class="h-4 w-4 text-indigo-600 dark:text-indigo-400" fill="currentColor" viewBox="0 0 20 20">
60
62
  <path fill-rule="evenodd" d="M14.707 12.707a1 1 0 01-1.414 0L10 9.414l-3.293 3.293a1 1 0 01-1.414-1.414l4-4a1 1 0 011.414 0l4 4a1 1 0 010 1.414z" clip-rule="evenodd"/>
61
63
  </svg>
62
64
  <% else %>
63
- <svg class="h-4 w-4 text-indigo-600" fill="currentColor" viewBox="0 0 20 20">
65
+ <svg class="h-4 w-4 text-indigo-600 dark:text-indigo-400" fill="currentColor" viewBox="0 0 20 20">
64
66
  <path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd"/>
65
67
  </svg>
66
68
  <% end %>
67
69
  <% if current_sorts.size > 1 %>
68
- <span class="text-[10px] font-bold text-indigo-600"><%= sort_index + 1 %></span>
70
+ <span class="text-[10px] font-bold text-indigo-600 dark:text-indigo-400"><%= sort_index + 1 %></span>
69
71
  <% end %>
70
72
  <% else %>
71
73
  <svg class="h-4 w-4 text-gray-400 opacity-0 group-hover:opacity-100 transition-opacity" fill="currentColor" viewBox="0 0 20 20">
@@ -81,7 +83,7 @@
81
83
  <% end %>
82
84
  </tr>
83
85
  </thead>
84
- <tbody id="<%= params[:table_name] %>_tbody" class="bg-white">
86
+ <tbody id="<%= params[:table_name] %>_tbody" class="bg-white dark:bg-gray-900">
85
87
  <%= render partial: "infotable/tables/table_rows", locals: { records: @records, table_class: @table_class } %>
86
88
  </tbody>
87
89
  </table>
@@ -96,8 +98,15 @@
96
98
  <svg class="mx-auto h-16 w-16 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
97
99
  <path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"/>
98
100
  </svg>
99
- <h3 class="mt-4 text-lg font-medium text-gray-900"><%= t('infotable.table.empty_state.title') %></h3>
100
- <p class="mt-2 text-sm text-gray-500"><%= t('infotable.table.empty_state.description') %></p>
101
+ <% if active_filters.any? %>
102
+ <h3 class="mt-4 text-lg font-medium text-gray-900 dark:text-gray-100"><%= t('infotable.table.empty_state.filtered_title') %></h3>
103
+ <p class="mt-2 text-sm text-gray-500 dark:text-gray-400"><%= t('infotable.table.empty_state.filtered_description') %></p>
104
+ <button type="button" data-action="click->infotable--table#clearAllFilters" data-empty-clear-filters
105
+ class="mt-4 inline-flex items-center rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-900 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 shadow-sm hover:bg-gray-50 dark:hover:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-indigo-500"><%= t('infotable.filters.active.clear_all') %></button>
106
+ <% else %>
107
+ <h3 class="mt-4 text-lg font-medium text-gray-900 dark:text-gray-100"><%= t('infotable.table.empty_state.title') %></h3>
108
+ <p class="mt-2 text-sm text-gray-500 dark:text-gray-400"><%= t('infotable.table.empty_state.description') %></p>
109
+ <% end %>
101
110
  </div>
102
111
  <% end %>
103
112
 
@@ -105,7 +114,7 @@
105
114
 
106
115
  <%# Pagination - Only visible when infinite scroll is disabled %>
107
116
  <% unless @infinite_scroll_enabled %>
108
- <div class="border-t border-gray-200 bg-white px-6 py-4">
117
+ <div class="border-t border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 px-6 py-4">
109
118
  <%= render "infotable/tables/pagination", pagy: @pagy, table_name: params[:table_name] %>
110
119
  </div>
111
120
  <% end %>
@@ -5,6 +5,8 @@ en:
5
5
  export_csv: "Export CSV"
6
6
  filters: "Filters"
7
7
  exporting: "Exporting..."
8
+ refresh: "Refresh"
9
+ columns: "Columns"
8
10
  sort_hint: "Click to sort, Shift+click to add a column to the sort"
9
11
  loading:
10
12
  initial: "Loading data..."
@@ -13,6 +15,8 @@ en:
13
15
  empty_state:
14
16
  title: "No data found"
15
17
  description: "Get started by adding some records to see them here."
18
+ filtered_title: "No rows match your filters"
19
+ filtered_description: "Remove a filter or search for something else."
16
20
 
17
21
  drawer:
18
22
  label: "Filters and columns"
@@ -39,6 +43,10 @@ en:
39
43
  clear_all: "Clear All"
40
44
  apply_filters: "Apply Filters"
41
45
  clear_filter: "Clear this filter"
46
+ active:
47
+ search: "Search: \u201c%{query}\u201d"
48
+ remove: "Remove filter %{filter}"
49
+ clear_all: "Clear all filters"
42
50
  from: "From"
43
51
  to: "To"
44
52
  min: "Min"
@@ -60,6 +68,19 @@ en:
60
68
  ends_with: "Ends with"
61
69
  is_empty: "Is empty"
62
70
  is_not_empty: "Is not empty"
71
+ range_ops:
72
+ between: "Between"
73
+ number_ops:
74
+ eq: "Equals"
75
+ neq: "Does not equal"
76
+ gt: "Greater than"
77
+ gte: "Greater than or equal"
78
+ lt: "Less than"
79
+ lte: "Less than or equal"
80
+ date_ops:
81
+ "on": "On"
82
+ before: "Before"
83
+ after: "After"
63
84
 
64
85
  formatters:
65
86
  ago: "%{time} ago"
@@ -73,6 +94,8 @@ en:
73
94
  clear: "Clear selection"
74
95
  select_all: "Select all rows"
75
96
  select_row: "Select row"
97
+ select_all_matching: "Select all %{count} matching"
98
+ truncated: "Only the first %{max} rows are selected"
76
99
 
77
100
  search:
78
101
  placeholder: "Search..."
@@ -5,6 +5,8 @@ pl:
5
5
  export_csv: "Eksportuj CSV"
6
6
  filters: "Filtry"
7
7
  exporting: "Eksportowanie..."
8
+ refresh: "Odśwież"
9
+ columns: "Kolumny"
8
10
  sort_hint: "Kliknij, aby sortować; Shift+klik dodaje kolumnę do sortowania"
9
11
  loading:
10
12
  initial: "Ładowanie danych..."
@@ -13,6 +15,8 @@ pl:
13
15
  empty_state:
14
16
  title: "Nie znaleziono danych"
15
17
  description: "Rozpocznij od dodania rekordów, aby zobaczyć je tutaj."
18
+ filtered_title: "Żaden wiersz nie pasuje do filtrów"
19
+ filtered_description: "Usuń filtr lub wyszukaj coś innego."
16
20
 
17
21
  drawer:
18
22
  label: "Filtry i kolumny"
@@ -39,6 +43,10 @@ pl:
39
43
  clear_all: "Wyczyść wszystkie"
40
44
  apply_filters: "Zastosuj filtry"
41
45
  clear_filter: "Wyczyść ten filtr"
46
+ active:
47
+ search: "Szukaj: \u201e%{query}\u201d"
48
+ remove: "Usuń filtr %{filter}"
49
+ clear_all: "Wyczyść wszystkie filtry"
42
50
  from: "Od"
43
51
  to: "Do"
44
52
  min: "Min"
@@ -60,6 +68,19 @@ pl:
60
68
  ends_with: "Kończy się na"
61
69
  is_empty: "Jest puste"
62
70
  is_not_empty: "Nie jest puste"
71
+ range_ops:
72
+ between: "Pomiędzy"
73
+ number_ops:
74
+ eq: "Równa się"
75
+ neq: "Różne od"
76
+ gt: "Większe niż"
77
+ gte: "Większe lub równe"
78
+ lt: "Mniejsze niż"
79
+ lte: "Mniejsze lub równe"
80
+ date_ops:
81
+ "on": "W dniu"
82
+ before: "Przed"
83
+ after: "Po"
63
84
 
64
85
  formatters:
65
86
  ago: "%{time} temu"
@@ -72,6 +93,8 @@ pl:
72
93
  selected: "Zaznaczono: %{count}"
73
94
  clear: "Wyczyść zaznaczenie"
74
95
  select_all: "Zaznacz wszystkie wiersze"
96
+ select_all_matching: "Zaznacz wszystkie pasujące (%{count})"
97
+ truncated: "Zaznaczono tylko pierwsze %{max} wierszy"
75
98
  select_row: "Zaznacz wiersz"
76
99
 
77
100
  search:
data/config/routes.rb CHANGED
@@ -3,4 +3,5 @@ Infotable::Engine.routes.draw do
3
3
  get "tables/:table_name", to: "tables#show", as: :table
4
4
  get "tables/:table_name/data", to: "tables#data", as: :table_data
5
5
  get "tables/:table_name/export", to: "tables#export", as: :table_export
6
+ get "tables/:table_name/ids", to: "tables#ids", as: :table_ids
6
7
  end
@@ -96,25 +96,6 @@ module Infotable
96
96
  template "initializer.rb", "config/initializers/infotable.rb"
97
97
  end
98
98
 
99
- def add_to_gitignore
100
- gitignore_entry = "# InfoTable - Generated from ERB template, do not commit\napp/assets/stylesheets/infotable.css\napp/assets/tailwind/infotable.css\n"
101
-
102
- if File.exist?(".gitignore")
103
- content = File.read(".gitignore")
104
- unless content.include?("infotable.css")
105
- append_to_file ".gitignore", "\n#{gitignore_entry}"
106
- say " ✅ Added generated infotable.css to .gitignore", :green
107
- else
108
- say " ℹ️ infotable.css already in .gitignore", :blue
109
- end
110
- else
111
- create_file ".gitignore", gitignore_entry
112
- say " ✅ Created .gitignore with infotable.css", :green
113
- end
114
-
115
- say " 📝 Commit infotable.css.erb (the template), not infotable.css (generated)", :cyan
116
- end
117
-
118
99
  def show_post_install_message
119
100
  say "\n" + "=" * 60, :green
120
101
  say "✅ InfoTable installation complete!", :green
@@ -144,9 +125,7 @@ module Infotable
144
125
  say "💡 Important Notes:", :cyan
145
126
  say "=" * 60, :cyan
146
127
  say "• InfoTable requires the tailwindcss-rails gem", :green
147
- say "• infotable.css.erb is processed by Rake task before Tailwind builds", :green
148
- say "• Gem paths resolve dynamically for each environment (dev/production)", :green
149
- say "• Commit infotable.css.erb (template), gitignore infotable.css (generated)", :cyan
128
+ say "• Tailwind scans the gem through @import \"../builds/tailwind/infotable\" in your Tailwind entry file", :green
150
129
 
151
130
  say "\n📖 Documentation: https://github.com/jcieslar/infotable", :cyan
152
131
  end
@@ -496,61 +475,28 @@ import InfoTableDrawerController from "infotable/controllers/drawer_controller"
496
475
  return
497
476
  end
498
477
 
499
- # Create separate infotable.css file with resolved gem paths
500
- create_infotable_css_file
501
-
502
- # Tailwind only scans the gem's views if the main CSS imports the generated file
478
+ # tailwindcss-rails writes app/assets/builds/tailwind/infotable.css (it imports the gem's
479
+ # app/assets/tailwind/infotable/engine.css) on every build; Tailwind only scans the gem's
480
+ # views if the main CSS imports it.
481
+ import = %(@import "../builds/tailwind/infotable";)
503
482
  content = File.read(tailwind_file)
504
- if content.match?(/@import\s+["']\.?\/?infotable["']/)
483
+ if content.include?(import)
505
484
  say " ℹ️ InfoTable import already exists in #{File.basename(tailwind_file)}", :blue
506
485
  else
507
- if content.include?("@import \"tailwindcss\"")
486
+ if content.match?(/@import\s+["']tailwindcss["'];?\s*\n/)
508
487
  inject_into_file tailwind_file, after: /@import\s+["']tailwindcss["'];?\s*\n/ do
509
- "\n/* InfoTable - Requires tailwindcss-rails gem */\n@import './infotable';\n"
488
+ "#{import}\n"
510
489
  end
511
490
  else
512
- prepend_to_file tailwind_file, "/* InfoTable - Requires tailwindcss-rails gem */\n@import './infotable';\n\n"
491
+ prepend_to_file tailwind_file, "#{import}\n"
513
492
  end
514
493
 
515
- say " ✅ Added @import './infotable' to #{File.basename(tailwind_file)}", :green
494
+ say " ✅ Added #{import} to #{File.basename(tailwind_file)}", :green
516
495
  end
517
496
 
518
497
  say " 📝 Run: rails tailwindcss:build", :cyan
519
498
  end
520
499
 
521
- def create_infotable_css_file
522
- # Determine the stylesheets directory
523
- stylesheets_dir = if File.exist?("app/assets/stylesheets")
524
- "app/assets/stylesheets"
525
- elsif File.exist?("app/assets/tailwind")
526
- "app/assets/tailwind"
527
- else
528
- "app/assets/stylesheets"
529
- end
530
-
531
- infotable_css_path = File.join(stylesheets_dir, "infotable.css.erb")
532
-
533
- # Create ERB file that dynamically resolves gem paths
534
- css_content = <<~CSS
535
- /* InfoTable Tailwind Sources - ERB Template */
536
- /* Requires: tailwindcss-rails gem (https://github.com/rails/tailwindcss-rails) */
537
- /* Paths are resolved dynamically via ERB from Infotable module */
538
-
539
- /* Local table definitions in your Rails app */
540
- @source "../../app/infotable";
541
-
542
- /* InfoTable gem paths (resolved via ERB at build time) */
543
- @source "<%= Infotable.views_path %>";
544
- @source "<%= Infotable.formatters_path %>";
545
- CSS
546
-
547
- create_file infotable_css_path, css_content
548
-
549
- say " ✅ Created #{infotable_css_path} with ERB templates", :green
550
- say " 📝 Gem paths resolve dynamically via Infotable module", :cyan
551
- say " 📝 Works in all environments automatically", :green
552
- end
553
-
554
500
  def package_json_has?(package)
555
501
  return false unless File.exist?("package.json")
556
502
 
@@ -116,6 +116,21 @@ module Infotable
116
116
  record.public_send(@checkbox_settings[:id_method])
117
117
  end
118
118
 
119
+ # Ids of every row the query matches (the "select all matching" button), at most `limit`.
120
+ # Plucks when the id method is a plain column of an ungrouped query, loads records otherwise.
121
+ def checkbox_ids(query, limit = nil)
122
+ method = @checkbox_settings[:id_method]
123
+ scope = query.reorder(nil)
124
+ scope = scope.limit(limit) if limit
125
+ model = query.model
126
+
127
+ if model.column_names.include?(method.to_s) && scope.group_values.empty? && scope.having_clause.empty?
128
+ scope.pluck(model.arel_table[method])
129
+ else
130
+ scope.map { |record| checkbox_value(record) }
131
+ end
132
+ end
133
+
119
134
  # Per-table access check. Block receives the controller; falsy => 403.
120
135
  # authorize { |controller| controller.current_user&.admin? }
121
136
  def authorize(&block)
@@ -157,6 +172,7 @@ module Infotable
157
172
  searchable: options.fetch(:searchable, false),
158
173
  filterable: options.fetch(:filterable, false),
159
174
  visible: options.fetch(:visible, true),
175
+ exportable: options.fetch(:exportable, !%i[action actions].include?(type)), # false keeps the column out of CSV exports
160
176
  pinned: options[:pinned], # :left, :right, or nil
161
177
  virtual: options.fetch(:virtual, false), # Virtual/computed columns (aggregates, subqueries)
162
178
  virtual_sql: options[:virtual_sql], # SQL expression for aggregate columns (used in HAVING clause)
@@ -322,6 +338,26 @@ module Infotable
322
338
  @filters_button_settings[:visible] = visible
323
339
  end
324
340
 
341
+ # Where the controls live. :header (default) is the bar above the table with title, search,
342
+ # export and filters buttons. :rail hides that bar and puts filters, columns, export and
343
+ # refresh on a slim vertical rail at the table's right edge; search moves into the drawer.
344
+ # @example
345
+ # toolbar :rail
346
+ def toolbar(mode)
347
+ unless [ :header, :rail ].include?(mode)
348
+ raise ArgumentError, "Invalid toolbar: #{mode}. Must be one of header, rail"
349
+ end
350
+ @toolbar_setting = mode
351
+ end
352
+
353
+ def toolbar_mode
354
+ @toolbar_setting || :header
355
+ end
356
+
357
+ def rail_toolbar?
358
+ toolbar_mode == :rail
359
+ end
360
+
325
361
  # Check if filters button is visible
326
362
  def filters_button_visible?
327
363
  @filters_button_settings ||= { visible: true }
@@ -444,6 +480,36 @@ module Infotable
444
480
  columns.select { |col| col[:visible] }
445
481
  end
446
482
 
483
+ # Columns a CSV export may contain (action columns and `exportable: false` are left out)
484
+ def exportable_columns
485
+ columns.select { |col| col[:exportable] }
486
+ end
487
+
488
+ # Which columns the CSV export contains and in which order, when the user has not chosen:
489
+ # export_columns :name, :email, :created_at
490
+ # Without it the export uses the visible, exportable columns in table order. With it, the
491
+ # list is also the pool the user can pick from (visible_columns can only narrow and reorder it).
492
+ def export_columns(*names)
493
+ @export_column_names = names.flatten.map(&:to_sym)
494
+ end
495
+
496
+ def export_column_names
497
+ @export_column_names
498
+ end
499
+
500
+ # The columns to export, as column definitions in export order. `requested` is the list of
501
+ # names the browser sends (the user's visible columns in their on-screen order).
502
+ def columns_for_export(requested = nil)
503
+ pool = exportable_columns
504
+ pool = export_column_names.map { |name| pool.find { |col| col[:name] == name } || raise(ArgumentError, "export_columns: unknown or non-exportable column #{name.inspect} in #{self.name}") } if export_column_names
505
+
506
+ by_name = pool.index_by { |col| col[:name].to_s }
507
+ picked = Array(requested).map { |name| by_name[name.to_s] }.compact.uniq
508
+ return picked if picked.any?
509
+
510
+ export_column_names ? pool : pool.select { |col| col[:visible] }
511
+ end
512
+
447
513
  # Get sortable columns
448
514
  def sortable_columns
449
515
  columns.select { |col| col[:sortable] }
@@ -14,7 +14,8 @@ module Infotable
14
14
  :empty_value,
15
15
  :parent_controller,
16
16
  :max_filter_options,
17
- :url_state
17
+ :url_state,
18
+ :max_select_all
18
19
 
19
20
  def initialize
20
21
  @default_per_page = 25
@@ -30,6 +31,7 @@ module Infotable
30
31
  @nil_value = "-" # Default placeholder for nil values
31
32
  @empty_value = "-" # Default placeholder for empty strings
32
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)
33
35
  @max_filter_options = 500 # select filters render at most this many <option>s (nil = no limit)
34
36
  @parent_controller = "ActionController::Base" # Set to "ApplicationController" to inherit host auth/filters
35
37
  end
@@ -39,13 +39,5 @@ module Infotable
39
39
  initializer "infotable.i18n" do |app|
40
40
  config.i18n.load_path += Dir[root.join("config/locales/**/*.yml")]
41
41
  end
42
-
43
- # Expose gem paths via ENV variables for Tailwind CSS v4 @source directives
44
- # This allows Tailwind to scan the gem's views/formatters at build time
45
- # Works in development, production, Docker, CI - everywhere!
46
- initializer "infotable.tailwind", before: :load_config_initializers do
47
- ENV["INFOTABLE_VIEWS_PATH"] = root.join("app/views/infotable").to_s
48
- ENV["INFOTABLE_FORMATTERS_PATH"] = root.join("lib/infotable/formatters").to_s
49
- end
50
42
  end
51
43
  end