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
|
@@ -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
|
-
|
|
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
|
-
|
|
323
|
-
if (!frame) return
|
|
401
|
+
this.searchTimeout = setTimeout(() => this.applySearch(value), 300)
|
|
402
|
+
}
|
|
324
403
|
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
url.searchParams.set("
|
|
332
|
-
|
|
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
|
-
|
|
335
|
-
|
|
336
|
-
|
|
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
|
-
#
|
|
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
|
|
@@ -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>
|