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
@@ -4,15 +4,18 @@ 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
+ resizable: { type: Boolean, default: true },
9
+ idsUrl: String
8
10
  }
9
11
 
10
- static targets = ["exportButton", "exportButtonText", "selectAll", "rowCheckbox", "batchBar", "batchCount"]
12
+ static targets = ["filterCount", "exportButton", "exportButtonText", "selectAll", "rowCheckbox", "batchBar", "batchCount", "selectAllMatching", "batchNote"]
11
13
 
12
14
  // Target callbacks (rowCheckboxTargetConnected) can fire before connect(), so state lives here
13
15
  initialize() {
14
16
  this.selected = new Set()
15
17
  this.selectionScope = null
18
+ this.totalCount = null
16
19
  }
17
20
 
18
21
  connect() {
@@ -22,6 +25,9 @@ export default class extends Controller {
22
25
  // Apply column visibility from localStorage when table loads
23
26
  this.applyColumnVisibility()
24
27
 
28
+ // Remembered column widths and the drag handles
29
+ this.applyColumnWidths()
30
+
25
31
  // Apply column pinning (sticky positioning)
26
32
  this.applyColumnPinning()
27
33
 
@@ -52,6 +58,7 @@ export default class extends Controller {
52
58
  applyOrderAndVisibility() {
53
59
  this.applyColumnOrder()
54
60
  this.applyColumnVisibility()
61
+ this.applyColumnWidths()
55
62
  this.applyColumnPinning()
56
63
  }
57
64
 
@@ -66,10 +73,34 @@ export default class extends Controller {
66
73
 
67
74
  // Handle turbo frame load
68
75
  handleFrameLoad(event) {
76
+ this.updateFilterCount(event.target)
77
+ // rows matching the current filters, when the server knows it (not with countless pagination)
78
+ const total = parseInt(event.target.querySelector("[data-total-count]")?.dataset.totalCount, 10)
79
+ this.totalCount = Number.isFinite(total) ? total : null
69
80
  this.syncSelectionScope(event.target)
70
81
  if (this.urlStateValue && event.target.id === `${this.nameValue}_content`) this.syncUrl(event.target)
71
82
  }
72
83
 
84
+ // Reload the current page of rows (rail toolbar's refresh button)
85
+ refresh() {
86
+ document.getElementById(`${this.nameValue}_content`)?.reload()
87
+ }
88
+
89
+ // Badge on the rail's filter button: number of columns with an active filter
90
+ updateFilterCount(frame) {
91
+ if (!this.hasFilterCountTarget || !frame?.src) return
92
+
93
+ const params = new URL(frame.src, window.location.href).searchParams
94
+ const columns = new Set()
95
+ for (const [key, value] of params) {
96
+ const match = key.match(/^filters\[([^\]]+)\]/)
97
+ if (match && value !== "") columns.add(match[1])
98
+ }
99
+
100
+ this.filterCountTarget.textContent = columns.size
101
+ this.filterCountTarget.classList.toggle("hidden", columns.size === 0)
102
+ }
103
+
73
104
  // ---- Table state in the address bar ---------------------------------------
74
105
  // The frame URL (`/infotable/tables/users_table/data?filters[..]&sort=..&page=2`) is mirrored in the
75
106
  // page URL as `?users_table[filters][..]=..&users_table[page]=2`, one namespace per table. The
@@ -202,6 +233,7 @@ export default class extends Controller {
202
233
 
203
234
  clearSelection() {
204
235
  this.selected.clear()
236
+ this.hideBatchNote()
205
237
  this.rowCheckboxTargets.forEach(checkbox => { checkbox.checked = false })
206
238
  this.renderSelection()
207
239
  }
@@ -216,19 +248,67 @@ export default class extends Controller {
216
248
  if (this.hasBatchCountTarget) {
217
249
  this.batchCountTarget.textContent = this.batchCountTarget.dataset.template.replace("__COUNT__", count)
218
250
  }
251
+ let allOnPageChecked = false
219
252
  if (this.hasSelectAllTarget) {
220
253
  const boxes = this.rowCheckboxTargets
221
254
  const checkedOnPage = boxes.filter(box => box.checked).length
222
- this.selectAllTarget.checked = boxes.length > 0 && checkedOnPage === boxes.length
255
+ allOnPageChecked = boxes.length > 0 && checkedOnPage === boxes.length
256
+ this.selectAllTarget.checked = allOnPageChecked
223
257
  this.selectAllTarget.indeterminate = checkedOnPage > 0 && checkedOnPage < boxes.length
224
258
  }
225
259
 
260
+ // Offer "select all N matching" once every loaded row is ticked and more rows match
261
+ if (this.hasSelectAllMatchingTarget) {
262
+ const offer = this.idsUrlValue !== "" && allOnPageChecked && this.totalCount !== null && this.totalCount > count
263
+ this.selectAllMatchingTarget.hidden = !offer
264
+ if (offer) {
265
+ this.selectAllMatchingTarget.textContent = this.selectAllMatchingTarget.dataset.template.replace("__COUNT__", this.totalCount)
266
+ }
267
+ }
268
+
226
269
  this.element.dispatchEvent(new CustomEvent("infotable:selection-changed", {
227
270
  bubbles: true,
228
271
  detail: { table: this.nameValue, ids: Array.from(this.selected) }
229
272
  }))
230
273
  }
231
274
 
275
+ // Fetches the ids of every row matching the current filters and search (not just the loaded
276
+ // pages) and adds them to the selection. The server caps the list (max_select_all).
277
+ async selectAllMatching(event) {
278
+ const button = event.currentTarget
279
+ const frame = document.getElementById(`${this.nameValue}_content`)
280
+ if (!frame || !this.idsUrlValue) return
281
+
282
+ const params = new URL(frame.src || frame.getAttribute("src"), window.location.href).searchParams
283
+ ;["page", "per_page", "sort", "sort_column", "sort_direction", "format"].forEach(key => params.delete(key))
284
+
285
+ button.disabled = true
286
+ try {
287
+ const response = await fetch(`${this.idsUrlValue}?${params}`, { headers: { Accept: "application/json" }, credentials: "same-origin" })
288
+ if (!response.ok) throw new Error(`HTTP ${response.status}`)
289
+ const { ids, truncated } = await response.json()
290
+
291
+ ids.forEach(id => this.selected.add(String(id)))
292
+ this.rowCheckboxTargets.forEach(box => { box.checked = this.selected.has(box.value) })
293
+ this.renderSelection()
294
+ if (truncated) this.showBatchNote(ids.length)
295
+ } catch (e) {
296
+ console.warn("Could not select all matching rows", e)
297
+ } finally {
298
+ button.disabled = false
299
+ }
300
+ }
301
+
302
+ showBatchNote(max) {
303
+ if (!this.hasBatchNoteTarget) return
304
+ this.batchNoteTarget.textContent = this.batchNoteTarget.dataset.template.replace("__MAX__", max)
305
+ this.batchNoteTarget.hidden = false
306
+ }
307
+
308
+ hideBatchNote() {
309
+ if (this.hasBatchNoteTarget) this.batchNoteTarget.hidden = true
310
+ }
311
+
232
312
  // Sends the selected ids as ids[] to the action's URL. GET navigates; other verbs
233
313
  // submit a form (with Rails' _method override and the CSRF token).
234
314
  runBatchAction(event) {
@@ -318,22 +398,51 @@ export default class extends Controller {
318
398
  clearTimeout(this.searchTimeout)
319
399
  const value = event.currentTarget.value.trim()
320
400
 
321
- this.searchTimeout = setTimeout(() => {
322
- const frame = document.getElementById(`${this.nameValue}_content`)
323
- if (!frame) return
401
+ this.searchTimeout = setTimeout(() => this.applySearch(value), 300)
402
+ }
324
403
 
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()
404
+ applySearch(value) {
405
+ const frame = document.getElementById(`${this.nameValue}_content`)
406
+ if (!frame) return
407
+
408
+ const url = new URL(frame.src || frame.getAttribute("src"), window.location.href)
409
+ if (value) {
410
+ url.searchParams.set("search", value)
411
+ } else {
412
+ url.searchParams.delete("search")
413
+ }
414
+ url.searchParams.set("page", "1")
415
+ frame.src = url.toString()
416
+
417
+ // Export link follows the frame's params
418
+ frame.addEventListener("turbo:frame-load", () => this.updateExportLink(), { once: true })
419
+ }
420
+
421
+ // The "x" on the search chip
422
+ clearSearch() {
423
+ clearTimeout(this.searchTimeout)
424
+ const input = this.element.querySelector('input[name="search"]')
425
+ if (input) input.value = ""
426
+ this.applySearch("")
427
+ }
428
+
429
+ // "Clear all filters" (chips bar and empty state): search and every filter, sort and per-page stay
430
+ clearAllFilters() {
431
+ clearTimeout(this.searchTimeout)
432
+ const input = this.element.querySelector('input[name="search"]')
433
+ if (input) input.value = ""
434
+ this.application.getControllerForElementAndIdentifier(this.element, "infotable--drawer")?.resetFilterInputs()
435
+
436
+ const frame = document.getElementById(`${this.nameValue}_content`)
437
+ if (!frame) return
333
438
 
334
- // Export link follows the frame's params
335
- frame.addEventListener("turbo:frame-load", () => this.updateExportLink(), { once: true })
336
- }, 300)
439
+ const url = new URL(frame.src || frame.getAttribute("src"), window.location.href)
440
+ Array.from(url.searchParams.keys()).forEach(key => {
441
+ if (key === "search" || key.startsWith("filters[")) url.searchParams.delete(key)
442
+ })
443
+ url.searchParams.set("page", "1")
444
+ frame.src = url.toString()
445
+ frame.addEventListener("turbo:frame-load", () => this.updateExportLink(), { once: true })
337
446
  }
338
447
 
339
448
  // Handle per-page change
@@ -676,6 +785,150 @@ export default class extends Controller {
676
785
  }
677
786
  }
678
787
 
788
+ // ---- Column widths ----------------------------------------------------------
789
+ // Every header gets a drag handle on its right edge (pointer drag, or arrow keys while it is
790
+ // focused). The width is written to the header and each cell of the column and remembered in
791
+ // localStorage as { columnName: px }. Double-click on a handle goes back to the table's width.
792
+
793
+ static MIN_COLUMN_WIDTH = 60
794
+ static MAX_COLUMN_WIDTH = 800
795
+
796
+ loadColumnWidths() {
797
+ try {
798
+ const stored = JSON.parse(localStorage.getItem(`infotable_column_widths_${this.nameValue}`) || "{}")
799
+ return stored && typeof stored === "object" ? stored : {}
800
+ } catch (e) {
801
+ return {}
802
+ }
803
+ }
804
+
805
+ saveColumnWidths(widths) {
806
+ try {
807
+ const key = `infotable_column_widths_${this.nameValue}`
808
+ Object.keys(widths).length ? localStorage.setItem(key, JSON.stringify(widths)) : localStorage.removeItem(key)
809
+ } catch (e) {
810
+ // storage unavailable (private window): the width still applies until the next load
811
+ }
812
+ }
813
+
814
+ columnTable() {
815
+ return this.element.querySelector("table") || document.getElementById(`${this.nameValue}_content`)?.querySelector("table")
816
+ }
817
+
818
+ // Writes one column's width to its header and cells. null restores the width the table was rendered with.
819
+ setColumnWidth(table, columnName, width) {
820
+ const escaped = CSS.escape(columnName)
821
+ const cells = table.querySelectorAll(`th[data-column-name="${escaped}"], td[data-column-name="${escaped}"]`)
822
+
823
+ cells.forEach(cell => {
824
+ if (cell.dataset.defaultWidth === undefined) cell.dataset.defaultWidth = cell.style.width
825
+ const value = width === null ? cell.dataset.defaultWidth : `${width}px`
826
+ cell.style.width = value
827
+ cell.style.maxWidth = value
828
+ cell.style.minWidth = width === null ? "" : value
829
+ })
830
+ }
831
+
832
+ applyColumnWidths() {
833
+ const table = this.columnTable()
834
+ if (!table) return
835
+
836
+ const widths = this.loadColumnWidths()
837
+ Object.entries(widths).forEach(([name, width]) => {
838
+ if (Number.isFinite(width)) this.setColumnWidth(table, name, width)
839
+ })
840
+
841
+ if (this.resizableValue) this.addResizeHandles(table)
842
+ }
843
+
844
+ addResizeHandles(table) {
845
+ table.querySelectorAll("thead th[data-column-name]").forEach(header => {
846
+ if (header.querySelector("[data-resize-handle]")) return
847
+
848
+ const handle = document.createElement("div")
849
+ handle.setAttribute("data-resize-handle", "")
850
+ handle.setAttribute("role", "separator")
851
+ handle.setAttribute("aria-orientation", "vertical")
852
+ handle.setAttribute("aria-label", `${header.textContent.trim()}: ${this.resizeLabel()}`)
853
+ handle.tabIndex = 0
854
+ handle.className = "absolute top-0 bottom-0 right-0 w-2 cursor-col-resize touch-none select-none hover:bg-indigo-400/40 focus:bg-indigo-400/60 focus:outline-none"
855
+
856
+ handle.addEventListener("pointerdown", event => this.startResize(event, header, handle))
857
+ handle.addEventListener("keydown", event => this.resizeWithKeys(event, header))
858
+ handle.addEventListener("dblclick", event => { event.stopPropagation(); this.resetColumnWidth(header) })
859
+ handle.addEventListener("click", event => { event.preventDefault(); event.stopPropagation() })
860
+
861
+ header.appendChild(handle)
862
+ })
863
+ }
864
+
865
+ resizeLabel() {
866
+ return this.element.dataset.resizeLabel || "resize column"
867
+ }
868
+
869
+ startResize(event, header, handle) {
870
+ if (event.button !== undefined && event.button !== 0) return
871
+ event.preventDefault()
872
+ event.stopPropagation()
873
+
874
+ const table = header.closest("table")
875
+ const name = header.dataset.columnName
876
+ const startX = event.clientX
877
+ const startWidth = header.getBoundingClientRect().width
878
+ let width = startWidth
879
+
880
+ handle.setPointerCapture?.(event.pointerId)
881
+
882
+ const move = moveEvent => {
883
+ width = this.clampColumnWidth(startWidth + (moveEvent.clientX - startX))
884
+ this.setColumnWidth(table, name, width)
885
+ }
886
+ const stop = () => {
887
+ handle.removeEventListener("pointermove", move)
888
+ handle.removeEventListener("pointerup", stop)
889
+ handle.removeEventListener("pointercancel", stop)
890
+ this.commitColumnWidth(name, width)
891
+ }
892
+
893
+ handle.addEventListener("pointermove", move)
894
+ handle.addEventListener("pointerup", stop)
895
+ handle.addEventListener("pointercancel", stop)
896
+ }
897
+
898
+ resizeWithKeys(event, header) {
899
+ const step = event.shiftKey ? 50 : 10
900
+ const delta = { ArrowLeft: -step, ArrowRight: step }[event.key]
901
+ if (delta === undefined) return
902
+
903
+ event.preventDefault()
904
+ event.stopPropagation()
905
+ const width = this.clampColumnWidth(header.getBoundingClientRect().width + delta)
906
+ this.setColumnWidth(header.closest("table"), header.dataset.columnName, width)
907
+ this.commitColumnWidth(header.dataset.columnName, width)
908
+ }
909
+
910
+ resetColumnWidth(header) {
911
+ const name = header.dataset.columnName
912
+ this.setColumnWidth(header.closest("table"), name, null)
913
+ const widths = this.loadColumnWidths()
914
+ delete widths[name]
915
+ this.saveColumnWidths(widths)
916
+ this.applyColumnPinning()
917
+ this.updateExportLink()
918
+ }
919
+
920
+ clampColumnWidth(width) {
921
+ return Math.round(Math.min(this.constructor.MAX_COLUMN_WIDTH, Math.max(this.constructor.MIN_COLUMN_WIDTH, width)))
922
+ }
923
+
924
+ commitColumnWidth(name, width) {
925
+ const widths = this.loadColumnWidths()
926
+ widths[name] = width
927
+ this.saveColumnWidths(widths)
928
+ // pinned columns are positioned from the widths of the columns before them
929
+ this.applyColumnPinning()
930
+ }
931
+
679
932
  // Apply column pinning (sticky positioning) from localStorage
680
933
  applyColumnPinning() {
681
934
  const tableName = this.nameValue
@@ -735,7 +988,7 @@ export default class extends Controller {
735
988
  header.style.removeProperty('opacity')
736
989
  header.style.backgroundClip = ''
737
990
  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]")
991
+ 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
992
  })
740
993
 
741
994
  rows.forEach(row => {
@@ -755,7 +1008,7 @@ export default class extends Controller {
755
1008
  cell.style.removeProperty('opacity')
756
1009
  cell.style.backgroundClip = ''
757
1010
  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]")
1011
+ 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
1012
  })
760
1013
  })
761
1014
 
@@ -804,7 +1057,7 @@ export default class extends Controller {
804
1057
 
805
1058
  // Set all styles at once via setAttribute (most aggressive method)
806
1059
  header.setAttribute('style', newStyles)
807
- header.classList.add('infotable-pinned-left', '!bg-gray-50')
1060
+ header.classList.add('infotable-pinned-left', '!bg-gray-50', 'dark:!bg-gray-800')
808
1061
 
809
1062
  // Mark last left-pinned column
810
1063
  if (columnName === lastLeftPinned) {
@@ -837,7 +1090,7 @@ export default class extends Controller {
837
1090
  `.replace(/\s+/g, ' ').trim()
838
1091
 
839
1092
  cell.setAttribute('style', newStyles)
840
- cell.classList.add('infotable-pinned-left', '!bg-white')
1093
+ cell.classList.add('infotable-pinned-left', '!bg-white', 'dark:!bg-gray-900')
841
1094
 
842
1095
  if (columnName === lastLeftPinned) {
843
1096
  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 +1123,7 @@ export default class extends Controller {
870
1123
  `.replace(/\s+/g, ' ').trim()
871
1124
 
872
1125
  header.setAttribute('style', newStyles)
873
- header.classList.add('infotable-pinned-right', '!bg-gray-50')
1126
+ header.classList.add('infotable-pinned-right', '!bg-gray-50', 'dark:!bg-gray-800')
874
1127
 
875
1128
  // Mark first right-pinned column
876
1129
  if (columnName === firstRightPinned) {
@@ -903,7 +1156,7 @@ export default class extends Controller {
903
1156
  `.replace(/\s+/g, ' ').trim()
904
1157
 
905
1158
  cell.setAttribute('style', newStyles)
906
- cell.classList.add('infotable-pinned-right', '!bg-white')
1159
+ cell.classList.add('infotable-pinned-right', '!bg-white', 'dark:!bg-gray-900')
907
1160
 
908
1161
  if (columnName === firstRightPinned) {
909
1162
  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
@@ -0,0 +1,24 @@
1
+ <%# Chips for the filters and search narrowing the table; each one removes itself. Locals: chips, table_name %>
2
+ <% if chips.any? %>
3
+ <div class="flex flex-wrap items-center gap-2 border-b border-gray-100 dark:border-gray-800 bg-white dark:bg-gray-900 px-4 sm:px-6 py-2" data-active-filters>
4
+ <% chips.each do |chip| %>
5
+ <% remove_action = chip[:search] ? "click->infotable--table#clearSearch" : "click->infotable--drawer#clearSingleFilter" %>
6
+ <% chip_label = chip[:search] ? chip[:text] : "#{chip[:label]}: #{chip[:text]}" %>
7
+ <span class="inline-flex items-center gap-1 rounded-full bg-indigo-50 dark:bg-indigo-950 py-1 pl-3 pr-1 text-xs font-medium text-indigo-700 dark:text-indigo-300" data-active-filter="<%= chip[:search] ? 'search' : chip[:name] %>">
8
+ <% unless chip[:search] %><span class="text-indigo-500 dark:text-indigo-400"><%= chip[:label] %>:</span><% end %>
9
+ <span><%= chip[:text] %></span>
10
+ <button type="button"
11
+ class="ml-0.5 inline-flex h-4 w-4 items-center justify-center rounded-full text-indigo-500 dark:text-indigo-400 hover:bg-indigo-100 dark:hover:bg-indigo-900 hover:text-indigo-800 dark:hover:text-indigo-200 focus:outline-none focus:ring-2 focus:ring-indigo-500"
12
+ aria-label="<%= t('infotable.filters.active.remove', filter: chip_label) %>"
13
+ data-action="<%= remove_action %>"
14
+ <% unless chip[:search] %>data-filter-name="<%= chip[:name] %>" data-filter-type="<%= chip[:type] %>"<% end %>>
15
+ <svg class="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/></svg>
16
+ </button>
17
+ </span>
18
+ <% end %>
19
+ <% if chips.size > 1 %>
20
+ <button type="button" data-action="click->infotable--table#clearAllFilters" data-clear-all-filters
21
+ class="text-xs font-medium text-gray-600 dark:text-gray-400 underline hover:text-gray-900 dark:hover:text-gray-100"><%= t('infotable.filters.active.clear_all') %></button>
22
+ <% end %>
23
+ </div>
24
+ <% end %>
@@ -1,7 +1,7 @@
1
1
  <%# Column Visibility Panel %>
2
2
  <div class="px-4 py-4">
3
3
  <div class="mb-3">
4
- <p class="text-sm text-gray-600"><%= t('infotable.columns.instructions') %></p>
4
+ <p class="text-sm text-gray-600 dark:text-gray-400"><%= t('infotable.columns.instructions') %></p>
5
5
  </div>
6
6
 
7
7
  <form id="column-visibility-form-<%= table_name %>"
@@ -11,13 +11,13 @@
11
11
  class="space-y-2">
12
12
 
13
13
  <% table_class.columns.each do |column| %>
14
- <div class="column-item group p-3 bg-white rounded-lg border border-gray-100 hover:border-indigo-200 transition-all duration-150"
14
+ <div class="column-item group p-3 bg-white dark:bg-gray-900 rounded-lg border border-gray-100 dark:border-gray-800 hover:border-indigo-200 dark:hover:border-indigo-700 transition-all duration-150"
15
15
  data-column-name="<%= column[:name] %>"
16
16
  data-default-visible="<%= column[:visible] %>"
17
17
  data-default-pinned="<%= column[:pinned] %>">
18
18
  <div class="flex items-center gap-3">
19
19
  <%# Drag Handle %>
20
- <div class="drag-handle cursor-move flex-shrink-0 text-gray-400 hover:text-gray-600 transition-colors">
20
+ <div class="drag-handle cursor-move flex-shrink-0 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors">
21
21
  <svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
22
22
  <path d="M7 2a2 2 0 1 0 .001 4.001A2 2 0 0 0 7 2zm0 6a2 2 0 1 0 .001 4.001A2 2 0 0 0 7 8zm0 6a2 2 0 1 0 .001 4.001A2 2 0 0 0 7 14zm6-8a2 2 0 1 0-.001-4.001A2 2 0 0 0 13 6zm0 2a2 2 0 1 0 .001 4.001A2 2 0 0 0 13 8zm0 6a2 2 0 1 0 .001 4.001A2 2 0 0 0 13 14z"></path>
23
23
  </svg>
@@ -32,9 +32,9 @@
32
32
  data-default-visible="<%= column[:visible] %>"
33
33
  data-action="change->infotable--drawer#updateColumnVisibility"
34
34
  <%= 'checked' if column[:visible] %>
35
- class="w-4 h-4 text-indigo-600 border-gray-300 rounded focus:ring-indigo-500 focus:ring-1 transition-colors">
35
+ class="w-4 h-4 text-indigo-600 dark:text-indigo-400 border-gray-300 dark:border-gray-600 rounded focus:ring-indigo-500 focus:ring-1 transition-colors">
36
36
  <div class="flex-1">
37
- <span class="text-sm font-medium text-gray-700"><%= column[:label] %></span>
37
+ <span class="text-sm font-medium text-gray-700 dark:text-gray-300"><%= column[:label] %></span>
38
38
  <% if column[:type] %>
39
39
  <span class="ml-2 text-xs text-gray-400">(<%= column[:type] %>)</span>
40
40
  <% end %>
@@ -49,7 +49,7 @@
49
49
  data-column-name="<%= column[:name] %>"
50
50
  data-pin-direction="left"
51
51
  title="<%= t('infotable.columns.pin.left') %>"
52
- class="pin-btn pin-left w-7 h-7 rounded flex items-center justify-center <%= column[:pinned] == :left ? 'bg-indigo-100 text-indigo-600' : 'text-gray-400 hover:bg-gray-100 hover:text-gray-600' %> transition-colors">
52
+ class="pin-btn pin-left w-7 h-7 rounded flex items-center justify-center <%= column[:pinned] == :left ? 'bg-indigo-100 dark:bg-indigo-900/40 text-indigo-600 dark:text-indigo-400' : 'text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-gray-600 dark:hover:text-gray-300' %> transition-colors">
53
53
  <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
54
54
  <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"></path>
55
55
  </svg>
@@ -60,7 +60,7 @@
60
60
  data-column-name="<%= column[:name] %>"
61
61
  data-pin-direction="right"
62
62
  title="<%= t('infotable.columns.pin.right') %>"
63
- class="pin-btn pin-right w-7 h-7 rounded flex items-center justify-center <%= column[:pinned] == :right ? 'bg-indigo-100 text-indigo-600' : 'text-gray-400 hover:bg-gray-100 hover:text-gray-600' %> transition-colors">
63
+ class="pin-btn pin-right w-7 h-7 rounded flex items-center justify-center <%= column[:pinned] == :right ? 'bg-indigo-100 dark:bg-indigo-900/40 text-indigo-600 dark:text-indigo-400' : 'text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-gray-600 dark:hover:text-gray-300' %> transition-colors">
64
64
  <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
65
65
  <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
66
66
  </svg>
@@ -73,18 +73,18 @@
73
73
  </form>
74
74
 
75
75
  <%# Footer Actions %>
76
- <div class="mt-4 pt-4 border-t border-gray-100 space-y-2">
76
+ <div class="mt-4 pt-4 border-t border-gray-100 dark:border-gray-800 space-y-2">
77
77
  <div class="flex gap-2">
78
78
  <button type="button"
79
79
  data-action="click->infotable--drawer#selectAllColumns"
80
80
  data-table-name="<%= table_name %>"
81
- class="flex-1 px-3 py-2 border border-gray-200 rounded-lg text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 hover:border-gray-300 focus:outline-none focus:ring-2 focus:ring-offset-1 focus:ring-indigo-500 transition-all">
81
+ class="flex-1 px-3 py-2 border border-gray-200 dark:border-gray-700 rounded-lg text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-900 hover:bg-gray-50 dark:hover:bg-gray-800 hover:border-gray-300 focus:outline-none focus:ring-2 focus:ring-offset-1 focus:ring-indigo-500 transition-all">
82
82
  <%= t('infotable.columns.actions.select_all') %>
83
83
  </button>
84
84
  <button type="button"
85
85
  data-action="click->infotable--drawer#deselectAllColumns"
86
86
  data-table-name="<%= table_name %>"
87
- class="flex-1 px-3 py-2 border border-gray-200 rounded-lg text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 hover:border-gray-300 focus:outline-none focus:ring-2 focus:ring-offset-1 focus:ring-indigo-500 transition-all">
87
+ class="flex-1 px-3 py-2 border border-gray-200 dark:border-gray-700 rounded-lg text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-900 hover:bg-gray-50 dark:hover:bg-gray-800 hover:border-gray-300 focus:outline-none focus:ring-2 focus:ring-offset-1 focus:ring-indigo-500 transition-all">
88
88
  <%= t('infotable.columns.actions.deselect_all') %>
89
89
  </button>
90
90
  </div>
@@ -93,7 +93,7 @@
93
93
  <button type="button"
94
94
  data-action="click->infotable--drawer#resetToDefaults"
95
95
  data-table-name="<%= table_name %>"
96
- class="w-full px-3 py-2 border border-red-200 rounded-lg text-sm font-medium text-red-700 bg-red-50 hover:bg-red-100 hover:border-red-300 focus:outline-none focus:ring-2 focus:ring-offset-1 focus:ring-red-500 transition-all">
96
+ class="w-full px-3 py-2 border border-red-200 rounded-lg text-sm font-medium text-red-700 dark:text-red-300 bg-red-50 hover:bg-red-100 hover:border-red-300 focus:outline-none focus:ring-2 focus:ring-offset-1 focus:ring-red-500 transition-all">
97
97
  <%= t('infotable.columns.actions.reset_to_defaults') %>
98
98
  </button>
99
99
  </div>