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.
- checksums.yaml +4 -4
- data/README.md +29 -2
- data/app/assets/javascripts/infotable/controllers/drawer_controller.js +68 -42
- data/app/assets/javascripts/infotable/controllers/table_controller.js +276 -23
- data/app/controllers/infotable/tables_controller.rb +17 -7
- data/app/helpers/infotable/tables_helper.rb +64 -2
- data/app/views/infotable/tables/_active_filters.html.erb +24 -0
- data/app/views/infotable/tables/_column_visibility.html.erb +11 -11
- data/app/views/infotable/tables/_filter_drawer.html.erb +4 -4
- data/app/views/infotable/tables/_next_page_frame.html.erb +5 -5
- data/app/views/infotable/tables/_pagination.html.erb +12 -12
- data/app/views/infotable/tables/_rail.html.erb +56 -0
- data/app/views/infotable/tables/_side_panel.html.erb +22 -10
- data/app/views/infotable/tables/_table.html.erb +45 -28
- data/app/views/infotable/tables/_table_rows.html.erb +3 -3
- data/app/views/infotable/tables/filters/_boolean.html.erb +2 -2
- data/app/views/infotable/tables/filters/_date.html.erb +2 -30
- data/app/views/infotable/tables/filters/_number.html.erb +2 -32
- data/app/views/infotable/tables/filters/_range.html.erb +66 -0
- data/app/views/infotable/tables/filters/_select.html.erb +4 -4
- data/app/views/infotable/tables/filters/_text.html.erb +3 -3
- data/app/views/infotable/tables/show.html.erb +22 -13
- data/config/locales/en.yml +24 -0
- data/config/locales/pl.yml +24 -0
- data/config/routes.rb +1 -0
- data/lib/infotable/base.rb +80 -1
- data/lib/infotable/configuration.rb +3 -1
- data/lib/infotable/formatters/action_formatter.rb +7 -7
- data/lib/infotable/formatters/avatar_formatter.rb +2 -2
- data/lib/infotable/formatters/badge_formatter.rb +9 -9
- data/lib/infotable/formatters/boolean_formatter.rb +2 -2
- data/lib/infotable/formatters/link_formatter.rb +1 -1
- data/lib/infotable/formatters/percent_formatter.rb +1 -1
- data/lib/infotable/version.rb +1 -1
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f5df1bcdf9da714e75a1f5b2f39681f69de99243954eefa1a936e1895d93370b
|
|
4
|
+
data.tar.gz: 8e0d96b405da00978ceaa3090a30dd310a1d6edc5ae1591ac50e96bede219bd7
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
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
|
-
|
|
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
|
-
|
|
334
|
-
|
|
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
|
-
//
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
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]")
|