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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c5a1b907c771796e4046d0c74dabfdb403255e7cc4eec0d9f6cda1464f2f61fb
4
- data.tar.gz: 352b469bb54ba80c3db5c05bfe9236be554575169af9354ff003d335e81131e4
3
+ metadata.gz: 171498a1e00eac672d386484ac90f7c98b51a7b05f627dd7da55c9ed6d384733
4
+ data.tar.gz: 3f6e330f094aec5c4d05ea11c46fe51cc0491bc6ebe80c88be42f0289c0f0716
5
5
  SHA512:
6
- metadata.gz: 5df9ceb7142c4db38807616045f6ff3dd2946fd0719031c661870e7fe6f5fced31a7de71a4e8ab3cf922ee70d51c1182faa1e9da1bbaa486385ae64d481007e5
7
- data.tar.gz: 1c40aa535af8a5b44fa1c3583cd09e4dbd3347a6cc4380e64e9fabc8fdda70dec9463a3b5cec22a10480810c05c50cbc6e91b14ca52ae590ab3eaefcbe81cfb7
6
+ metadata.gz: e5bc91b7d123b62cf9db4a4814a98b389ac12d3082a913da6f6d6593cf07f545e5ece6373d408407a3503fb1697d977a523b48cff194ae488bac8c76bb7e5322
7
+ data.tar.gz: 502b8de5cd278b4f5c3ebfb184984eabb82e1260fcb048c236fb0e799f9f41ea1bedede69cc811101a4626b39b915634603c96a2b61c983905f8747096bf1908
data/README.md CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  Modern, flexible datatable engine for Rails 8+ with Hotwire. Build beautiful, interactive tables with server-side filtering, sorting, searching, and pagination—without page reloads.
4
4
 
5
+ **[Live demo](https://infotable.infovation.eu/)** · [Changelog](CHANGELOG.md) · [Contributing](CONTRIBUTING.md)
6
+
5
7
  ## Features
6
8
 
7
9
  - ✨ **Server-side operations** - Filtering, sorting, searching, pagination all on the backend
@@ -350,6 +352,8 @@ Click a header to sort; **Shift+click** adds up to three sort columns (`?sort=na
350
352
 
351
353
  Mark columns `searchable: true` to get a search box. Every word (up to 5) must match at least one searchable column, case-insensitively.
352
354
 
355
+ The box sits in the header bar. With `toolbar :rail` there is no header, so it moves to the top of the drawer: open it with the rail's filters or columns button. It works the same there (debounced, kept across sorting, filters and CSV export, restored from the URL) and is shown only when at least one column is `searchable`.
356
+
353
357
  #### Filters
354
358
 
355
359
  Filter types are inferred from the column type or set with `filter_type:`.
@@ -357,7 +361,7 @@ Filter types are inferred from the column type or set with `filter_type:`.
357
361
  | Type | Operators |
358
362
  |------|-----------|
359
363
  | text | contains, equals, starts with, ends with, not contains, is empty, is not empty |
360
- | number | `eq` `neq` `gt` `gte` `lt` `lte` `between` `is_empty` `is_not_empty` (plain `from`/`to` still work) |
364
+ | number | `eq` `neq` `gt` `gte` `lt` `lte` `between` `is_empty` `is_not_empty` (plain `from`/`to` still work; the drawer shows them as "Between") |
361
365
  | date | `on` `before` `after` `between` `is_empty` `is_not_empty` (plain `from`/`to` still work) |
362
366
  | boolean | any, yes, no |
363
367
  | select | one value, or several with `filter_multi_select: true` |
@@ -446,6 +450,14 @@ row_size :default
446
450
  - **Mid**: Moderate padding, normal text - balanced appearance
447
451
  - **Default**: Generous padding, normal text - comfortable reading
448
452
 
453
+ #### Toolbar
454
+
455
+ ```ruby
456
+ toolbar :rail # :header (default) or :rail
457
+ ```
458
+
459
+ `:rail` hides the header bar and puts filters (with a badge of active filters), columns, CSV export and refresh on a slim vertical rail at the table's right edge. Search moves to the top of the drawer.
460
+
449
461
  #### Filters Button Visibility
450
462
 
451
463
  Show or hide the filters button:
@@ -1034,7 +1046,7 @@ end
1034
1046
 
1035
1047
  ## Contributing
1036
1048
 
1037
- Bug reports and pull requests are welcome on GitHub at https://github.com/jcieslar/infotable.
1049
+ Bug reports and pull requests are welcome on GitHub at https://github.com/jcieslar/infotable. See [CONTRIBUTING.md](CONTRIBUTING.md) for the setup and the checks a change has to pass, and [SECURITY.md](SECURITY.md) for reporting vulnerabilities.
1038
1050
 
1039
1051
  ---
1040
1052
 
@@ -310,29 +310,34 @@ export default class extends Controller {
310
310
  // Clear all filters
311
311
  clearFilters(event) {
312
312
  event.preventDefault()
313
+ if (this.resetFilterInputs()) {
314
+ // Submit immediately (no debounce for clear)
315
+ this.submitFilters(null)
316
+ }
317
+ }
318
+
319
+ // Empties every filter input in the drawer without reloading the table. False when there is no form.
320
+ resetFilterInputs() {
313
321
  const form = this.contentTarget.querySelector("form")
314
- if (form) {
315
- // Clear debounce timer
316
- if (this.debounceTimer) {
317
- clearTimeout(this.debounceTimer)
318
- this.debounceTimer = null
319
- }
322
+ if (!form) return false
320
323
 
321
- // Reset all inputs except hidden ones (sort, per_page)
322
- const inputs = form.querySelectorAll('input:not([type="hidden"]), select, textarea')
323
- inputs.forEach(input => {
324
- if (input.type === 'checkbox' || input.type === 'radio') {
325
- input.checked = false
326
- } else {
327
- input.value = ''
328
- }
329
- })
324
+ // Clear debounce timer
325
+ if (this.debounceTimer) {
326
+ clearTimeout(this.debounceTimer)
327
+ this.debounceTimer = null
328
+ }
330
329
 
331
- this.syncFilterOps()
330
+ // Reset all inputs except hidden ones (sort, per_page)
331
+ form.querySelectorAll('input:not([type="hidden"]), select, textarea').forEach(input => {
332
+ if (input.type === 'checkbox' || input.type === 'radio') {
333
+ input.checked = false
334
+ } else {
335
+ input.value = ''
336
+ }
337
+ })
332
338
 
333
- // Submit immediately (no debounce for clear)
334
- this.submitFilters(null)
335
- }
339
+ this.syncFilterOps()
340
+ return true
336
341
  }
337
342
 
338
343
  // Clear a single filter
@@ -368,11 +373,13 @@ export default class extends Controller {
368
373
 
369
374
  case 'number':
370
375
  case 'date':
371
- // Clear both from and to fields
372
- const fromInput = field('from')
373
- const toInput = field('to')
374
- if (fromInput) fromInput.value = ''
375
- if (toInput) toInput.value = ''
376
+ // Back to the plain from/to range
377
+ ;['from', 'to', 'value'].forEach(suffix => {
378
+ const input = field(suffix)
379
+ if (input) input.value = ''
380
+ })
381
+ const rangeOp = field('op')
382
+ if (rangeOp) rangeOp.value = ''
376
383
  break
377
384
 
378
385
  case 'select':
@@ -411,6 +418,7 @@ export default class extends Controller {
411
418
 
412
419
  // Hide the value input for operators that take none (is_empty / is_not_empty)
413
420
  syncFilterOps() {
421
+ this.syncRangeOps()
414
422
  this.element.querySelectorAll("select[data-filter-op]").forEach(select => {
415
423
  const container = select.closest("[data-filter-container]")
416
424
  const wrapper = container?.querySelector("[data-filter-value-wrapper]")
@@ -423,6 +431,24 @@ export default class extends Controller {
423
431
  })
424
432
  }
425
433
 
434
+ // Number and date filters: the operator decides between from/to inputs, one value input, or none
435
+ syncRangeOps() {
436
+ this.element.querySelectorAll("select[data-range-op]").forEach(select => {
437
+ const container = select.closest("[data-range-container]")
438
+ if (!container) return
439
+
440
+ const op = select.value
441
+ const mode = op === "" ? "range" : (op === "is_empty" || op === "is_not_empty") ? "none" : "single"
442
+
443
+ container.querySelectorAll("[data-range-fields]").forEach(group => {
444
+ const visible = group.dataset.rangeFields === mode
445
+ group.style.display = visible ? "" : "none"
446
+ // inputs that are not shown must not leak into the request
447
+ if (!visible) group.querySelectorAll("input").forEach(input => { input.value = "" })
448
+ })
449
+ })
450
+ }
451
+
426
452
  // Tab switching
427
453
  switchTab(event) {
428
454
  event.preventDefault()
@@ -435,11 +461,11 @@ export default class extends Controller {
435
461
  this.tabButtonTargets.forEach(button => {
436
462
  const isActive = button.dataset.tab === tab
437
463
  if (isActive) {
438
- button.classList.add("text-indigo-600", "border-indigo-600")
439
- button.classList.remove("text-gray-500", "border-transparent")
464
+ button.classList.add("text-indigo-600", "dark:text-indigo-400", "border-indigo-600")
465
+ button.classList.remove("text-gray-500", "dark:text-gray-400", "border-transparent")
440
466
  } else {
441
- button.classList.remove("text-indigo-600", "border-indigo-600")
442
- button.classList.add("text-gray-500", "border-transparent")
467
+ button.classList.remove("text-indigo-600", "dark:text-indigo-400", "border-indigo-600")
468
+ button.classList.add("text-gray-500", "dark:text-gray-400", "border-transparent")
443
469
  }
444
470
  })
445
471
 
@@ -878,21 +904,21 @@ export default class extends Controller {
878
904
 
879
905
  if (leftBtn) {
880
906
  if (pinned === 'left') {
881
- leftBtn.classList.add('bg-indigo-100', 'text-indigo-600')
882
- leftBtn.classList.remove('text-gray-400', 'hover:bg-gray-100', 'hover:text-gray-600')
907
+ leftBtn.classList.add('bg-indigo-100', 'dark:bg-indigo-900/40', 'text-indigo-600')
908
+ leftBtn.classList.remove('text-gray-400', 'hover:bg-gray-100', 'dark:hover:bg-gray-700', 'hover:text-gray-600')
883
909
  } else {
884
- leftBtn.classList.remove('bg-indigo-100', 'text-indigo-600')
885
- leftBtn.classList.add('text-gray-400', 'hover:bg-gray-100', 'hover:text-gray-600')
910
+ leftBtn.classList.remove('bg-indigo-100', 'dark:bg-indigo-900/40', 'text-indigo-600')
911
+ leftBtn.classList.add('text-gray-400', 'hover:bg-gray-100', 'dark:hover:bg-gray-700', 'hover:text-gray-600')
886
912
  }
887
913
  }
888
914
 
889
915
  if (rightBtn) {
890
916
  if (pinned === 'right') {
891
- rightBtn.classList.add('bg-indigo-100', 'text-indigo-600')
892
- rightBtn.classList.remove('text-gray-400', 'hover:bg-gray-100', 'hover:text-gray-600')
917
+ rightBtn.classList.add('bg-indigo-100', 'dark:bg-indigo-900/40', 'text-indigo-600')
918
+ rightBtn.classList.remove('text-gray-400', 'hover:bg-gray-100', 'dark:hover:bg-gray-700', 'hover:text-gray-600')
893
919
  } else {
894
- rightBtn.classList.remove('bg-indigo-100', 'text-indigo-600')
895
- rightBtn.classList.add('text-gray-400', 'hover:bg-gray-100', 'hover:text-gray-600')
920
+ rightBtn.classList.remove('bg-indigo-100', 'dark:bg-indigo-900/40', 'text-indigo-600')
921
+ rightBtn.classList.add('text-gray-400', 'hover:bg-gray-100', 'dark:hover:bg-gray-700', 'hover:text-gray-600')
896
922
  }
897
923
  }
898
924
  }
@@ -965,7 +991,7 @@ export default class extends Controller {
965
991
  header.style.removeProperty('background-color')
966
992
  header.style.removeProperty('background-image')
967
993
  header.style.removeProperty('opacity')
968
- header.classList.remove('infotable-pinned-left', 'infotable-pinned-right', '!bg-gray-50', "after:content-['']", "after:absolute", "after:top-0", "after:right-0", "after:bottom-0", "after:w-5", "after:pointer-events-none", "after:bg-gradient-to-r", "after:from-transparent", "after:to-black/[0.04]", "after:z-[1]", "before:content-['']", "before:absolute", "before:top-0", "before:left-0", "before:bottom-0", "before:w-5", "before:pointer-events-none", "before:bg-gradient-to-l", "before:from-transparent", "before:to-black/[0.04]", "before:z-[1]")
994
+ header.classList.remove('infotable-pinned-left', 'infotable-pinned-right', '!bg-gray-50', 'dark:!bg-gray-800', "after:content-['']", "after:absolute", "after:top-0", "after:right-0", "after:bottom-0", "after:w-5", "after:pointer-events-none", "after:bg-gradient-to-r", "after:from-transparent", "after:to-black/[0.04]", "after:z-[1]", "before:content-['']", "before:absolute", "before:top-0", "before:left-0", "before:bottom-0", "before:w-5", "before:pointer-events-none", "before:bg-gradient-to-l", "before:from-transparent", "before:to-black/[0.04]", "before:z-[1]")
969
995
  })
970
996
 
971
997
  rows.forEach(row => {
@@ -978,7 +1004,7 @@ export default class extends Controller {
978
1004
  cell.style.removeProperty('background-color')
979
1005
  cell.style.removeProperty('background-image')
980
1006
  cell.style.removeProperty('opacity')
981
- cell.classList.remove('infotable-pinned-left', 'infotable-pinned-right', '!bg-white', "after:content-['']", "after:absolute", "after:top-0", "after:right-0", "after:bottom-0", "after:w-5", "after:pointer-events-none", "after:bg-gradient-to-r", "after:from-transparent", "after:to-black/[0.04]", "after:z-[1]", "before:content-['']", "before:absolute", "before:top-0", "before:left-0", "before:bottom-0", "before:w-5", "before:pointer-events-none", "before:bg-gradient-to-l", "before:from-transparent", "before:to-black/[0.04]", "before:z-[1]")
1007
+ cell.classList.remove('infotable-pinned-left', 'infotable-pinned-right', '!bg-white', 'dark:!bg-gray-900', "after:content-['']", "after:absolute", "after:top-0", "after:right-0", "after:bottom-0", "after:w-5", "after:pointer-events-none", "after:bg-gradient-to-r", "after:from-transparent", "after:to-black/[0.04]", "after:z-[1]", "before:content-['']", "before:absolute", "before:top-0", "before:left-0", "before:bottom-0", "before:w-5", "before:pointer-events-none", "before:bg-gradient-to-l", "before:from-transparent", "before:to-black/[0.04]", "before:z-[1]")
982
1008
  })
983
1009
  })
984
1010
 
@@ -1024,7 +1050,7 @@ export default class extends Controller {
1024
1050
 
1025
1051
  // Set all styles at once via setAttribute
1026
1052
  header.setAttribute('style', newStyles)
1027
- header.classList.add('infotable-pinned-left', '!bg-gray-50')
1053
+ header.classList.add('infotable-pinned-left', '!bg-gray-50', 'dark:!bg-gray-800')
1028
1054
 
1029
1055
  // Mark last left-pinned column
1030
1056
  if (columnName === lastLeftPinned) {
@@ -1055,7 +1081,7 @@ export default class extends Controller {
1055
1081
  `.replace(/\s+/g, ' ').trim()
1056
1082
 
1057
1083
  cell.setAttribute('style', newStyles)
1058
- cell.classList.add('infotable-pinned-left', '!bg-white')
1084
+ cell.classList.add('infotable-pinned-left', '!bg-white', 'dark:!bg-gray-900')
1059
1085
 
1060
1086
  if (columnName === lastLeftPinned) {
1061
1087
  cell.classList.add("after:content-['']", "after:absolute", "after:top-0", "after:right-0", "after:bottom-0", "after:w-5", "after:pointer-events-none", "after:bg-gradient-to-r", "after:from-transparent", "after:to-black/[0.04]", "after:z-[1]")
@@ -1085,7 +1111,7 @@ export default class extends Controller {
1085
1111
  `.replace(/\s+/g, ' ').trim()
1086
1112
 
1087
1113
  header.setAttribute('style', newStyles)
1088
- header.classList.add('infotable-pinned-right', '!bg-gray-50')
1114
+ header.classList.add('infotable-pinned-right', '!bg-gray-50', 'dark:!bg-gray-800')
1089
1115
 
1090
1116
  // Mark first right-pinned column
1091
1117
  if (columnName === firstRightPinned) {
@@ -1116,7 +1142,7 @@ export default class extends Controller {
1116
1142
  `.replace(/\s+/g, ' ').trim()
1117
1143
 
1118
1144
  cell.setAttribute('style', newStyles)
1119
- cell.classList.add('infotable-pinned-right', '!bg-white')
1145
+ cell.classList.add('infotable-pinned-right', '!bg-white', 'dark:!bg-gray-900')
1120
1146
 
1121
1147
  if (columnName === firstRightPinned) {
1122
1148
  cell.classList.add("before:content-['']", "before:absolute", "before:top-0", "before:left-0", "before:bottom-0", "before:w-5", "before:pointer-events-none", "before:bg-gradient-to-l", "before:from-transparent", "before:to-black/[0.04]", "before:z-[1]")
@@ -4,15 +4,17 @@ import { Controller } from "@hotwired/stimulus"
4
4
  export default class extends Controller {
5
5
  static values = {
6
6
  name: String,
7
- urlState: Boolean
7
+ urlState: Boolean,
8
+ idsUrl: String
8
9
  }
9
10
 
10
- static targets = ["exportButton", "exportButtonText", "selectAll", "rowCheckbox", "batchBar", "batchCount"]
11
+ static targets = ["filterCount", "exportButton", "exportButtonText", "selectAll", "rowCheckbox", "batchBar", "batchCount", "selectAllMatching", "batchNote"]
11
12
 
12
13
  // Target callbacks (rowCheckboxTargetConnected) can fire before connect(), so state lives here
13
14
  initialize() {
14
15
  this.selected = new Set()
15
16
  this.selectionScope = null
17
+ this.totalCount = null
16
18
  }
17
19
 
18
20
  connect() {
@@ -66,10 +68,34 @@ export default class extends Controller {
66
68
 
67
69
  // Handle turbo frame load
68
70
  handleFrameLoad(event) {
71
+ this.updateFilterCount(event.target)
72
+ // rows matching the current filters, when the server knows it (not with countless pagination)
73
+ const total = parseInt(event.target.querySelector("[data-total-count]")?.dataset.totalCount, 10)
74
+ this.totalCount = Number.isFinite(total) ? total : null
69
75
  this.syncSelectionScope(event.target)
70
76
  if (this.urlStateValue && event.target.id === `${this.nameValue}_content`) this.syncUrl(event.target)
71
77
  }
72
78
 
79
+ // Reload the current page of rows (rail toolbar's refresh button)
80
+ refresh() {
81
+ document.getElementById(`${this.nameValue}_content`)?.reload()
82
+ }
83
+
84
+ // Badge on the rail's filter button: number of columns with an active filter
85
+ updateFilterCount(frame) {
86
+ if (!this.hasFilterCountTarget || !frame?.src) return
87
+
88
+ const params = new URL(frame.src, window.location.href).searchParams
89
+ const columns = new Set()
90
+ for (const [key, value] of params) {
91
+ const match = key.match(/^filters\[([^\]]+)\]/)
92
+ if (match && value !== "") columns.add(match[1])
93
+ }
94
+
95
+ this.filterCountTarget.textContent = columns.size
96
+ this.filterCountTarget.classList.toggle("hidden", columns.size === 0)
97
+ }
98
+
73
99
  // ---- Table state in the address bar ---------------------------------------
74
100
  // The frame URL (`/infotable/tables/users_table/data?filters[..]&sort=..&page=2`) is mirrored in the
75
101
  // page URL as `?users_table[filters][..]=..&users_table[page]=2`, one namespace per table. The
@@ -202,6 +228,7 @@ export default class extends Controller {
202
228
 
203
229
  clearSelection() {
204
230
  this.selected.clear()
231
+ this.hideBatchNote()
205
232
  this.rowCheckboxTargets.forEach(checkbox => { checkbox.checked = false })
206
233
  this.renderSelection()
207
234
  }
@@ -216,19 +243,67 @@ export default class extends Controller {
216
243
  if (this.hasBatchCountTarget) {
217
244
  this.batchCountTarget.textContent = this.batchCountTarget.dataset.template.replace("__COUNT__", count)
218
245
  }
246
+ let allOnPageChecked = false
219
247
  if (this.hasSelectAllTarget) {
220
248
  const boxes = this.rowCheckboxTargets
221
249
  const checkedOnPage = boxes.filter(box => box.checked).length
222
- this.selectAllTarget.checked = boxes.length > 0 && checkedOnPage === boxes.length
250
+ allOnPageChecked = boxes.length > 0 && checkedOnPage === boxes.length
251
+ this.selectAllTarget.checked = allOnPageChecked
223
252
  this.selectAllTarget.indeterminate = checkedOnPage > 0 && checkedOnPage < boxes.length
224
253
  }
225
254
 
255
+ // Offer "select all N matching" once every loaded row is ticked and more rows match
256
+ if (this.hasSelectAllMatchingTarget) {
257
+ const offer = this.idsUrlValue !== "" && allOnPageChecked && this.totalCount !== null && this.totalCount > count
258
+ this.selectAllMatchingTarget.hidden = !offer
259
+ if (offer) {
260
+ this.selectAllMatchingTarget.textContent = this.selectAllMatchingTarget.dataset.template.replace("__COUNT__", this.totalCount)
261
+ }
262
+ }
263
+
226
264
  this.element.dispatchEvent(new CustomEvent("infotable:selection-changed", {
227
265
  bubbles: true,
228
266
  detail: { table: this.nameValue, ids: Array.from(this.selected) }
229
267
  }))
230
268
  }
231
269
 
270
+ // Fetches the ids of every row matching the current filters and search (not just the loaded
271
+ // pages) and adds them to the selection. The server caps the list (max_select_all).
272
+ async selectAllMatching(event) {
273
+ const button = event.currentTarget
274
+ const frame = document.getElementById(`${this.nameValue}_content`)
275
+ if (!frame || !this.idsUrlValue) return
276
+
277
+ const params = new URL(frame.src || frame.getAttribute("src"), window.location.href).searchParams
278
+ ;["page", "per_page", "sort", "sort_column", "sort_direction", "format"].forEach(key => params.delete(key))
279
+
280
+ button.disabled = true
281
+ try {
282
+ const response = await fetch(`${this.idsUrlValue}?${params}`, { headers: { Accept: "application/json" }, credentials: "same-origin" })
283
+ if (!response.ok) throw new Error(`HTTP ${response.status}`)
284
+ const { ids, truncated } = await response.json()
285
+
286
+ ids.forEach(id => this.selected.add(String(id)))
287
+ this.rowCheckboxTargets.forEach(box => { box.checked = this.selected.has(box.value) })
288
+ this.renderSelection()
289
+ if (truncated) this.showBatchNote(ids.length)
290
+ } catch (e) {
291
+ console.warn("Could not select all matching rows", e)
292
+ } finally {
293
+ button.disabled = false
294
+ }
295
+ }
296
+
297
+ showBatchNote(max) {
298
+ if (!this.hasBatchNoteTarget) return
299
+ this.batchNoteTarget.textContent = this.batchNoteTarget.dataset.template.replace("__MAX__", max)
300
+ this.batchNoteTarget.hidden = false
301
+ }
302
+
303
+ hideBatchNote() {
304
+ if (this.hasBatchNoteTarget) this.batchNoteTarget.hidden = true
305
+ }
306
+
232
307
  // Sends the selected ids as ids[] to the action's URL. GET navigates; other verbs
233
308
  // submit a form (with Rails' _method override and the CSRF token).
234
309
  runBatchAction(event) {
@@ -318,22 +393,51 @@ export default class extends Controller {
318
393
  clearTimeout(this.searchTimeout)
319
394
  const value = event.currentTarget.value.trim()
320
395
 
321
- this.searchTimeout = setTimeout(() => {
322
- const frame = document.getElementById(`${this.nameValue}_content`)
323
- if (!frame) return
396
+ this.searchTimeout = setTimeout(() => this.applySearch(value), 300)
397
+ }
324
398
 
325
- const url = new URL(frame.src || frame.getAttribute("src"), window.location.href)
326
- if (value) {
327
- url.searchParams.set("search", value)
328
- } else {
329
- url.searchParams.delete("search")
330
- }
331
- url.searchParams.set("page", "1")
332
- frame.src = url.toString()
399
+ applySearch(value) {
400
+ const frame = document.getElementById(`${this.nameValue}_content`)
401
+ if (!frame) return
402
+
403
+ const url = new URL(frame.src || frame.getAttribute("src"), window.location.href)
404
+ if (value) {
405
+ url.searchParams.set("search", value)
406
+ } else {
407
+ url.searchParams.delete("search")
408
+ }
409
+ url.searchParams.set("page", "1")
410
+ frame.src = url.toString()
411
+
412
+ // Export link follows the frame's params
413
+ frame.addEventListener("turbo:frame-load", () => this.updateExportLink(), { once: true })
414
+ }
415
+
416
+ // The "x" on the search chip
417
+ clearSearch() {
418
+ clearTimeout(this.searchTimeout)
419
+ const input = this.element.querySelector('input[name="search"]')
420
+ if (input) input.value = ""
421
+ this.applySearch("")
422
+ }
423
+
424
+ // "Clear all filters" (chips bar and empty state): search and every filter, sort and per-page stay
425
+ clearAllFilters() {
426
+ clearTimeout(this.searchTimeout)
427
+ const input = this.element.querySelector('input[name="search"]')
428
+ if (input) input.value = ""
429
+ this.application.getControllerForElementAndIdentifier(this.element, "infotable--drawer")?.resetFilterInputs()
333
430
 
334
- // Export link follows the frame's params
335
- frame.addEventListener("turbo:frame-load", () => this.updateExportLink(), { once: true })
336
- }, 300)
431
+ const frame = document.getElementById(`${this.nameValue}_content`)
432
+ if (!frame) return
433
+
434
+ const url = new URL(frame.src || frame.getAttribute("src"), window.location.href)
435
+ Array.from(url.searchParams.keys()).forEach(key => {
436
+ if (key === "search" || key.startsWith("filters[")) url.searchParams.delete(key)
437
+ })
438
+ url.searchParams.set("page", "1")
439
+ frame.src = url.toString()
440
+ frame.addEventListener("turbo:frame-load", () => this.updateExportLink(), { once: true })
337
441
  }
338
442
 
339
443
  // Handle per-page change
@@ -735,7 +839,7 @@ export default class extends Controller {
735
839
  header.style.removeProperty('opacity')
736
840
  header.style.backgroundClip = ''
737
841
  header.style.boxShadow = ''
738
- header.classList.remove('infotable-pinned-left', 'infotable-pinned-right', '!bg-gray-50', "after:content-['']", "after:absolute", "after:top-0", "after:right-0", "after:bottom-0", "after:w-5", "after:pointer-events-none", "after:bg-gradient-to-r", "after:from-transparent", "after:to-black/[0.04]", "after:z-[1]", "before:content-['']", "before:absolute", "before:top-0", "before:left-0", "before:bottom-0", "before:w-5", "before:pointer-events-none", "before:bg-gradient-to-l", "before:from-transparent", "before:to-black/[0.04]", "before:z-[1]")
842
+ header.classList.remove('infotable-pinned-left', 'infotable-pinned-right', '!bg-gray-50', 'dark:!bg-gray-800', "after:content-['']", "after:absolute", "after:top-0", "after:right-0", "after:bottom-0", "after:w-5", "after:pointer-events-none", "after:bg-gradient-to-r", "after:from-transparent", "after:to-black/[0.04]", "after:z-[1]", "before:content-['']", "before:absolute", "before:top-0", "before:left-0", "before:bottom-0", "before:w-5", "before:pointer-events-none", "before:bg-gradient-to-l", "before:from-transparent", "before:to-black/[0.04]", "before:z-[1]")
739
843
  })
740
844
 
741
845
  rows.forEach(row => {
@@ -755,7 +859,7 @@ export default class extends Controller {
755
859
  cell.style.removeProperty('opacity')
756
860
  cell.style.backgroundClip = ''
757
861
  cell.style.boxShadow = ''
758
- cell.classList.remove('infotable-pinned-left', 'infotable-pinned-right', '!bg-white', "after:content-['']", "after:absolute", "after:top-0", "after:right-0", "after:bottom-0", "after:w-5", "after:pointer-events-none", "after:bg-gradient-to-r", "after:from-transparent", "after:to-black/[0.04]", "after:z-[1]", "before:content-['']", "before:absolute", "before:top-0", "before:left-0", "before:bottom-0", "before:w-5", "before:pointer-events-none", "before:bg-gradient-to-l", "before:from-transparent", "before:to-black/[0.04]", "before:z-[1]")
862
+ cell.classList.remove('infotable-pinned-left', 'infotable-pinned-right', '!bg-white', 'dark:!bg-gray-900', "after:content-['']", "after:absolute", "after:top-0", "after:right-0", "after:bottom-0", "after:w-5", "after:pointer-events-none", "after:bg-gradient-to-r", "after:from-transparent", "after:to-black/[0.04]", "after:z-[1]", "before:content-['']", "before:absolute", "before:top-0", "before:left-0", "before:bottom-0", "before:w-5", "before:pointer-events-none", "before:bg-gradient-to-l", "before:from-transparent", "before:to-black/[0.04]", "before:z-[1]")
759
863
  })
760
864
  })
761
865
 
@@ -804,7 +908,7 @@ export default class extends Controller {
804
908
 
805
909
  // Set all styles at once via setAttribute (most aggressive method)
806
910
  header.setAttribute('style', newStyles)
807
- header.classList.add('infotable-pinned-left', '!bg-gray-50')
911
+ header.classList.add('infotable-pinned-left', '!bg-gray-50', 'dark:!bg-gray-800')
808
912
 
809
913
  // Mark last left-pinned column
810
914
  if (columnName === lastLeftPinned) {
@@ -837,7 +941,7 @@ export default class extends Controller {
837
941
  `.replace(/\s+/g, ' ').trim()
838
942
 
839
943
  cell.setAttribute('style', newStyles)
840
- cell.classList.add('infotable-pinned-left', '!bg-white')
944
+ cell.classList.add('infotable-pinned-left', '!bg-white', 'dark:!bg-gray-900')
841
945
 
842
946
  if (columnName === lastLeftPinned) {
843
947
  cell.classList.add("after:content-['']", "after:absolute", "after:top-0", "after:right-0", "after:bottom-0", "after:w-5", "after:pointer-events-none", "after:bg-gradient-to-r", "after:from-transparent", "after:to-black/[0.04]", "after:z-[1]")
@@ -870,7 +974,7 @@ export default class extends Controller {
870
974
  `.replace(/\s+/g, ' ').trim()
871
975
 
872
976
  header.setAttribute('style', newStyles)
873
- header.classList.add('infotable-pinned-right', '!bg-gray-50')
977
+ header.classList.add('infotable-pinned-right', '!bg-gray-50', 'dark:!bg-gray-800')
874
978
 
875
979
  // Mark first right-pinned column
876
980
  if (columnName === firstRightPinned) {
@@ -903,7 +1007,7 @@ export default class extends Controller {
903
1007
  `.replace(/\s+/g, ' ').trim()
904
1008
 
905
1009
  cell.setAttribute('style', newStyles)
906
- cell.classList.add('infotable-pinned-right', '!bg-white')
1010
+ cell.classList.add('infotable-pinned-right', '!bg-white', 'dark:!bg-gray-900')
907
1011
 
908
1012
  if (columnName === firstRightPinned) {
909
1013
  cell.classList.add("before:content-['']", "before:absolute", "before:top-0", "before:left-0", "before:bottom-0", "before:w-5", "before:pointer-events-none", "before:bg-gradient-to-l", "before:from-transparent", "before:to-black/[0.04]", "before:z-[1]")
@@ -57,19 +57,29 @@ module Infotable
57
57
  end
58
58
  end
59
59
 
60
+ # Ids of every row matching the current filters and search, for "select all matching".
61
+ # Same authorization and query as the table; capped by Infotable.configuration.max_select_all.
62
+ def ids
63
+ return head :not_found unless @table_class.checkbox_enabled?
64
+
65
+ cap = Infotable.configuration.max_select_all
66
+ ids = @table_class.checkbox_ids(build_query, cap && cap + 1)
67
+ truncated = cap && ids.size > cap
68
+ ids = ids.first(cap) if truncated
69
+
70
+ render json: { ids: ids.map(&:to_s), truncated: truncated ? true : false }
71
+ end
72
+
60
73
  def export
61
74
  require "csv"
62
75
 
63
76
  # Process the same query as show/data but without pagination
64
77
  @query = build_query
65
78
 
66
- # Get visible columns from params or use all visible columns
67
- visible_column_names = if params[:visible_columns].present?
68
- params[:visible_columns].split(",").map(&:strip)
69
- else
70
- @table_class.visible_columns.map { |c| c[:name].to_s }
71
- end
72
- visible_columns = @table_class.columns.select { |col| visible_column_names.include?(col[:name].to_s) }
79
+ # The browser sends the user's visible columns in their on-screen order; the table decides
80
+ # what may be exported (see Base.columns_for_export)
81
+ requested = scalar_param(:visible_columns).to_s.split(",").map(&:strip)
82
+ visible_columns = @table_class.columns_for_export(requested)
73
83
 
74
84
  # Set CSV headers for streaming download
75
85
  headers["Content-Type"] = "text/csv; charset=utf-8"
@@ -258,7 +258,7 @@ module Infotable
258
258
 
259
259
  {
260
260
  style: "#{side}: 0; width: #{width}; max-width: 90%; transform: translateX(#{hidden});",
261
- class: "absolute top-0 bottom-0 bg-white #{side == 'left' ? 'border-r rounded-l-xl' : 'border-l rounded-r-xl'} border-indigo-200 shadow-2xl transition-all duration-300 ease-in-out z-50",
261
+ class: "absolute top-0 bottom-0 bg-white dark:bg-gray-900 #{side == 'left' ? 'border-r rounded-l-xl' : 'border-l rounded-r-xl'} border-indigo-200 dark:border-indigo-800 shadow-2xl transition-all duration-300 ease-in-out z-50",
262
262
  role: "dialog",
263
263
  inert: true, # closed drawer is off-screen: keep it out of the tab order (JS toggles it)
264
264
  aria: { label: I18n.t("infotable.drawer.label"), hidden: true },
@@ -267,13 +267,75 @@ module Infotable
267
267
  end
268
268
 
269
269
  BATCH_BUTTON_COLORS = {
270
- default: "border-gray-300 bg-white text-gray-700 hover:bg-gray-50",
270
+ default: "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",
271
271
  primary: "border-indigo-600 bg-indigo-600 text-white hover:bg-indigo-700",
272
272
  success: "border-green-600 bg-green-600 text-white hover:bg-green-700",
273
273
  warning: "border-yellow-500 bg-yellow-500 text-white hover:bg-yellow-600",
274
274
  danger: "border-red-600 bg-red-600 text-white hover:bg-red-700"
275
275
  }.freeze
276
276
 
277
+ NUMBER_OP_SYMBOLS = { "eq" => "=", "neq" => "\u2260", "gt" => ">", "gte" => "\u2265", "lt" => "<", "lte" => "\u2264" }.freeze
278
+
279
+ # The filters and search that are narrowing the table right now, as chips:
280
+ # [{ name: "age", type: "number", label: "Age", text: "\u2265 45" }, { search: true, text: "..." }]
281
+ # Only filterable columns with something to apply are listed; blank inputs are not active.
282
+ def infotable_active_filters(table_class, filters, search = nil)
283
+ filters = filters.to_unsafe_h if filters.respond_to?(:to_unsafe_h)
284
+ filters = {} unless filters.is_a?(Hash)
285
+ filters = filters.with_indifferent_access
286
+
287
+ chips = table_class.filterable_columns.filter_map do |column|
288
+ params = filters[column[:name]]
289
+ type = filter_type_for_column(column)
290
+ text = infotable_filter_chip_text(type, params)
291
+ next if text.blank?
292
+
293
+ { name: column[:name].to_s, type: type, label: column[:label].to_s, text: text }
294
+ end
295
+
296
+ chips.unshift(search: true, text: t("infotable.filters.active.search", query: search)) if search.present?
297
+ chips
298
+ end
299
+
300
+ def infotable_filter_chip_text(type, params)
301
+ case type
302
+ when "select", "boolean"
303
+ raw = params.is_a?(Hash) ? params[:value] : params
304
+ values = Array(raw).flatten.reject { |v| v.is_a?(Hash) || v.blank? }.map(&:to_s)
305
+ return if values.empty?
306
+
307
+ values.map { |v| type == "boolean" ? infotable_boolean_chip_text(v) : v }.join(", ")
308
+ else
309
+ return unless params.is_a?(Hash)
310
+
311
+ op = params[:op].to_s
312
+ return t("infotable.filters.text_ops.#{op}") if %w[is_empty is_not_empty].include?(op)
313
+
314
+ value = params[:value].is_a?(String) ? params[:value].strip : nil
315
+ from = params[:from].is_a?(String) ? params[:from].strip : nil
316
+ to = params[:to].is_a?(String) ? params[:to].strip : nil
317
+
318
+ if type == "text"
319
+ return if value.blank?
320
+
321
+ "#{t("infotable.filters.text_ops.#{op.presence_in(%w[contains exact not_contain starts_with ends_with]) || 'contains'}")} \u201c#{value}\u201d"
322
+ elsif op.present? && op != "between" && value.present?
323
+ type == "number" ? "#{NUMBER_OP_SYMBOLS.fetch(op, op)} #{value}" : "#{t("infotable.filters.date_ops.#{op}", default: op)} #{value}"
324
+ elsif from.present? && to.present?
325
+ "#{from} \u2013 #{to}"
326
+ elsif from.present?
327
+ "\u2265 #{from}"
328
+ elsif to.present?
329
+ "\u2264 #{to}"
330
+ end
331
+ end
332
+ end
333
+
334
+ def infotable_boolean_chip_text(value)
335
+ key = { "true" => "true", "false" => "false", "is_empty" => "empty", "is_not_empty" => "not_empty" }[value]
336
+ key ? t("infotable.filters.boolean.#{key}") : value
337
+ end
338
+
277
339
  def infotable_batch_button_classes(color)
278
340
  "inline-flex items-center px-3 py-1.5 border text-sm font-medium rounded-lg transition-colors #{BATCH_BUTTON_COLORS.fetch(color, BATCH_BUTTON_COLORS[:default])}"
279
341
  end