infotable 1.0.1 → 1.3.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 +29 -2
  3. data/app/assets/javascripts/infotable/controllers/drawer_controller.js +68 -42
  4. data/app/assets/javascripts/infotable/controllers/table_controller.js +276 -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 +45 -28
  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 +24 -0
  24. data/config/locales/pl.yml +24 -0
  25. data/config/routes.rb +1 -0
  26. data/lib/infotable/base.rb +80 -1
  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: f5df1bcdf9da714e75a1f5b2f39681f69de99243954eefa1a936e1895d93370b
4
+ data.tar.gz: 8e0d96b405da00978ceaa3090a30dd310a1d6edc5ae1591ac50e96bede219bd7
5
5
  SHA512:
6
- metadata.gz: 5df9ceb7142c4db38807616045f6ff3dd2946fd0719031c661870e7fe6f5fced31a7de71a4e8ab3cf922ee70d51c1182faa1e9da1bbaa486385ae64d481007e5
7
- data.tar.gz: 1c40aa535af8a5b44fa1c3583cd09e4dbd3347a6cc4380e64e9fabc8fdda70dec9463a3b5cec22a10480810c05c50cbc6e91b14ca52ae590ab3eaefcbe81cfb7
6
+ metadata.gz: ca2f4b93b865921e6d8b8d62d8fabd3d935cd6db93600e3ecd2934df6d58e17930cb22bbf1f4bddeaa78c28dbedba7a3e6a4ff205320f144488dcffd432c950a
7
+ data.tar.gz: 2d86610f937536dca157b8d99ee9265f77e652ea0772ef3c96fc0306dd7a3f3b3d2712bd8524b427e548f600b63f5694efbc636f818ba1afd4e737b54cc34332
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` |
@@ -368,6 +372,21 @@ Filters, search and sort survive pagination, per-page changes and CSV export.
368
372
 
369
373
  Search, filters, sort, page and page size are mirrored in the page's address bar, one namespace per table (`/users?users_table[search]=ann&users_table[page]=2`). Links can be shared and bookmarked, a reload keeps the table as it was, and the Back button steps through the states. Turn it off globally with `config.url_state = false` or per table with `url_state false` (e.g. tables inside modals or tabs).
370
374
 
375
+ #### Column widths, dark mode, small screens
376
+
377
+ - Every header has a drag handle on its right edge (keyboard: focus it, Left/Right, Shift for bigger steps; double-click resets). Widths are remembered in the browser. `resizable_columns false` removes the handles.
378
+ - Dark mode follows `prefers-color-scheme`. If your app switches with a `.dark` class instead, add `@custom-variant dark (&:where(.dark, .dark *));` to your Tailwind CSS.
379
+ - On narrow screens the toolbar stacks and the table scrolls sideways inside its own box.
380
+
381
+ #### CSV export columns
382
+
383
+ The export contains the columns the user sees, in the order they see them; action columns are never exported. Pin it down per table:
384
+
385
+ ```ruby
386
+ export_columns :name, :email, :created_at # default list, and the only columns that can be exported
387
+ column :internal_note, exportable: false # never exported
388
+ ```
389
+
371
390
  #### Large tables
372
391
 
373
392
  ```ruby
@@ -446,6 +465,14 @@ row_size :default
446
465
  - **Mid**: Moderate padding, normal text - balanced appearance
447
466
  - **Default**: Generous padding, normal text - comfortable reading
448
467
 
468
+ #### Toolbar
469
+
470
+ ```ruby
471
+ toolbar :rail # :header (default) or :rail
472
+ ```
473
+
474
+ `: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.
475
+
449
476
  #### Filters Button Visibility
450
477
 
451
478
  Show or hide the filters button:
@@ -1034,7 +1061,7 @@ end
1034
1061
 
1035
1062
  ## Contributing
1036
1063
 
1037
- Bug reports and pull requests are welcome on GitHub at https://github.com/jcieslar/infotable.
1064
+ 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
1065
 
1039
1066
  ---
1040
1067
 
@@ -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]")