infotable 1.0.1 → 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 (35) 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/controllers/infotable/tables_controller.rb +17 -7
  6. data/app/helpers/infotable/tables_helper.rb +64 -2
  7. data/app/views/infotable/tables/_active_filters.html.erb +24 -0
  8. data/app/views/infotable/tables/_column_visibility.html.erb +11 -11
  9. data/app/views/infotable/tables/_filter_drawer.html.erb +4 -4
  10. data/app/views/infotable/tables/_next_page_frame.html.erb +5 -5
  11. data/app/views/infotable/tables/_pagination.html.erb +12 -12
  12. data/app/views/infotable/tables/_rail.html.erb +56 -0
  13. data/app/views/infotable/tables/_side_panel.html.erb +22 -10
  14. data/app/views/infotable/tables/_table.html.erb +43 -26
  15. data/app/views/infotable/tables/_table_rows.html.erb +3 -3
  16. data/app/views/infotable/tables/filters/_boolean.html.erb +2 -2
  17. data/app/views/infotable/tables/filters/_date.html.erb +2 -30
  18. data/app/views/infotable/tables/filters/_number.html.erb +2 -32
  19. data/app/views/infotable/tables/filters/_range.html.erb +66 -0
  20. data/app/views/infotable/tables/filters/_select.html.erb +4 -4
  21. data/app/views/infotable/tables/filters/_text.html.erb +3 -3
  22. data/app/views/infotable/tables/show.html.erb +22 -13
  23. data/config/locales/en.yml +23 -0
  24. data/config/locales/pl.yml +23 -0
  25. data/config/routes.rb +1 -0
  26. data/lib/infotable/base.rb +66 -0
  27. data/lib/infotable/configuration.rb +3 -1
  28. data/lib/infotable/formatters/action_formatter.rb +7 -7
  29. data/lib/infotable/formatters/avatar_formatter.rb +2 -2
  30. data/lib/infotable/formatters/badge_formatter.rb +9 -9
  31. data/lib/infotable/formatters/boolean_formatter.rb +2 -2
  32. data/lib/infotable/formatters/link_formatter.rb +1 -1
  33. data/lib/infotable/formatters/percent_formatter.rb +1 -1
  34. data/lib/infotable/version.rb +1 -1
  35. metadata +4 -1
@@ -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
@@ -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
@@ -72,7 +72,7 @@ module Infotable
72
72
  action[:label] || action[:icon] || view_context.t("infotable.formatters.action"),
73
73
  resolve_url(action[:url], record),
74
74
  role: "menuitem",
75
- class: "block px-3 py-1.5 text-sm whitespace-nowrap #{link_classes(action[:color])} hover:bg-gray-50",
75
+ class: "block px-3 py-1.5 text-sm whitespace-nowrap #{link_classes(action[:color])} hover:bg-gray-50 dark:hover:bg-gray-800",
76
76
  data: link_data(action[:data] || {}, action[:method] || :get, action[:confirm])
77
77
  )
78
78
  end
@@ -81,10 +81,10 @@ module Infotable
81
81
  # <details> gives open/close and keyboard support without JS
82
82
  content_tag(:details, class: "relative inline-block text-left") do
83
83
  summary = content_tag(:summary, "\u22EF".html_safe,
84
- class: "list-none cursor-pointer inline-flex items-center px-2 py-1 rounded-md border border-gray-300 bg-white text-gray-600 hover:bg-gray-50",
84
+ class: "list-none cursor-pointer inline-flex items-center px-2 py-1 rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-900 text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-800",
85
85
  "aria-label": view_context.t("infotable.formatters.action"), "aria-haspopup": "menu")
86
86
  menu = content_tag(:div, role: "menu",
87
- class: "absolute right-0 z-30 mt-1 min-w-36 rounded-md border border-gray-200 bg-white py-1 shadow-lg") do
87
+ class: "absolute right-0 z-30 mt-1 min-w-36 rounded-md border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 py-1 shadow-lg") do
88
88
  safe_join(actions.map { |action| render_menu_item(action, record) })
89
89
  end
90
90
  summary + menu
@@ -110,7 +110,7 @@ module Infotable
110
110
  when "success"
111
111
  "border-green-600 bg-green-600 text-white hover:bg-green-700"
112
112
  else
113
- "border-gray-300 bg-white text-gray-700 hover:bg-gray-50"
113
+ "border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-900 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-800"
114
114
  end
115
115
 
116
116
  "#{base} #{color_classes}"
@@ -119,11 +119,11 @@ module Infotable
119
119
  def link_classes(color = nil)
120
120
  case color&.to_s
121
121
  when "danger"
122
- "text-red-600 hover:text-red-900"
122
+ "text-red-600 dark:text-red-400 hover:text-red-900 dark:hover:text-red-300"
123
123
  when "success"
124
- "text-green-600 hover:text-green-900"
124
+ "text-green-600 dark:text-green-400 hover:text-green-900 dark:hover:text-green-300"
125
125
  else
126
- "text-indigo-600 hover:text-indigo-900"
126
+ "text-indigo-600 dark:text-indigo-400 hover:text-indigo-900 dark:hover:text-indigo-200"
127
127
  end
128
128
  end
129
129
 
@@ -10,8 +10,8 @@ module Infotable
10
10
  class AvatarFormatter < BaseFormatter
11
11
  SIZES = { sm: "h-6 w-6 text-xs", md: "h-8 w-8 text-sm", lg: "h-10 w-10 text-base" }.freeze
12
12
  COLORS = [
13
- "bg-indigo-100 text-indigo-700", "bg-green-100 text-green-700", "bg-yellow-100 text-yellow-700",
14
- "bg-pink-100 text-pink-700", "bg-blue-100 text-blue-700", "bg-purple-100 text-purple-700"
13
+ "bg-indigo-100 dark:bg-indigo-900/40 text-indigo-700 dark:text-indigo-300", "bg-green-100 dark:bg-green-900/40 text-green-700 dark:text-green-300", "bg-yellow-100 dark:bg-yellow-900/40 text-yellow-700 dark:text-yellow-300",
14
+ "bg-pink-100 dark:bg-pink-900/40 text-pink-700 dark:text-pink-300", "bg-blue-100 dark:bg-blue-900/40 text-blue-700 dark:text-blue-300", "bg-purple-100 dark:bg-purple-900/40 text-purple-700 dark:text-purple-300"
15
15
  ].freeze
16
16
 
17
17
  def format(value, record, options = {})
@@ -54,23 +54,23 @@ module Infotable
54
54
 
55
55
  color_classes = case color.to_s
56
56
  when "green"
57
- "bg-green-100 text-green-800"
57
+ "bg-green-100 dark:bg-green-900/40 text-green-800 dark:text-green-300"
58
58
  when "red"
59
- "bg-red-100 text-red-800"
59
+ "bg-red-100 dark:bg-red-900/40 text-red-800 dark:text-red-300"
60
60
  when "yellow"
61
- "bg-yellow-100 text-yellow-800"
61
+ "bg-yellow-100 dark:bg-yellow-900/40 text-yellow-800 dark:text-yellow-300"
62
62
  when "blue"
63
- "bg-blue-100 text-blue-800"
63
+ "bg-blue-100 dark:bg-blue-900/40 text-blue-800 dark:text-blue-300"
64
64
  when "indigo"
65
- "bg-indigo-100 text-indigo-800"
65
+ "bg-indigo-100 dark:bg-indigo-900/40 text-indigo-800 dark:text-indigo-300"
66
66
  when "purple"
67
- "bg-purple-100 text-purple-800"
67
+ "bg-purple-100 dark:bg-purple-900/40 text-purple-800 dark:text-purple-300"
68
68
  when "pink"
69
- "bg-pink-100 text-pink-800"
69
+ "bg-pink-100 dark:bg-pink-900/40 text-pink-800 dark:text-pink-300"
70
70
  when "cyan"
71
- "bg-cyan-100 text-cyan-800"
71
+ "bg-cyan-100 dark:bg-cyan-900/40 text-cyan-800 dark:text-cyan-300"
72
72
  else # gray
73
- "bg-gray-100 text-gray-800"
73
+ "bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-200"
74
74
  end
75
75
 
76
76
  "#{base} #{size_classes} #{color_classes}"
@@ -20,9 +20,9 @@ module Infotable
20
20
 
21
21
  def format_as_badge(value, true_text, false_text)
22
22
  if value
23
- content_tag(:span, true_text, class: "inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800")
23
+ content_tag(:span, true_text, class: "inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 dark:bg-green-900/40 text-green-800 dark:text-green-300")
24
24
  else
25
- content_tag(:span, false_text, class: "inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800")
25
+ content_tag(:span, false_text, class: "inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-200")
26
26
  end
27
27
  end
28
28
 
@@ -10,7 +10,7 @@ module Infotable
10
10
  # Without url: the value itself is the URL (handy for website columns); mailto:/tel: work too.
11
11
  class LinkFormatter < BaseFormatter
12
12
  ALLOWED_SCHEMES = %w[http https mailto tel].freeze
13
- DEFAULT_CLASSES = "text-indigo-600 hover:text-indigo-800 hover:underline".freeze
13
+ DEFAULT_CLASSES = "text-indigo-600 dark:text-indigo-400 hover:text-indigo-800 dark:hover:text-indigo-200 hover:underline".freeze
14
14
 
15
15
  def format(value, record, options = {})
16
16
  label = resolve(options[:label], record) || value
@@ -19,7 +19,7 @@ module Infotable
19
19
 
20
20
  width = number.clamp(0, 100).round
21
21
  bar = content_tag(:div, content_tag(:div, "", class: "h-full rounded-full bg-indigo-500", style: "width: #{width}%"),
22
- class: "h-1.5 w-16 rounded-full bg-gray-200 flex-none", role: "presentation")
22
+ class: "h-1.5 w-16 rounded-full bg-gray-200 dark:bg-gray-700 flex-none", role: "presentation")
23
23
  content_tag(:div, safe_join([ content_tag(:span, text, class: "tabular-nums"), bar ]), class: "flex items-center gap-2")
24
24
  end
25
25
  end
@@ -1,3 +1,3 @@
1
1
  module Infotable
2
- VERSION = "1.0.1"
2
+ VERSION = "1.2.0"
3
3
  end