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.
- checksums.yaml +4 -4
- data/README.md +14 -2
- data/app/assets/javascripts/infotable/controllers/drawer_controller.js +68 -42
- data/app/assets/javascripts/infotable/controllers/table_controller.js +127 -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 +43 -26
- 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 +23 -0
- data/config/locales/pl.yml +23 -0
- data/config/routes.rb +1 -0
- data/lib/infotable/base.rb +66 -0
- 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: 171498a1e00eac672d386484ac90f7c98b51a7b05f627dd7da55c9ed6d384733
|
|
4
|
+
data.tar.gz: 3f6e330f094aec5c4d05ea11c46fe51cc0491bc6ebe80c88be42f0289c0f0716
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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]")
|
|
@@ -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
|
-
|
|
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
|
-
|
|
323
|
-
if (!frame) return
|
|
396
|
+
this.searchTimeout = setTimeout(() => this.applySearch(value), 300)
|
|
397
|
+
}
|
|
324
398
|
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
url.searchParams.set("
|
|
332
|
-
|
|
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
|
-
|
|
335
|
-
|
|
336
|
-
|
|
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
|
-
#
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|