infotable 0.1.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 (58) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +1043 -0
  4. data/Rakefile +6 -0
  5. data/app/assets/javascripts/infotable/controllers/drawer_controller.js +1412 -0
  6. data/app/assets/javascripts/infotable/controllers/table_controller.js +921 -0
  7. data/app/assets/javascripts/infotable/index.js +14 -0
  8. data/app/controllers/infotable/application_controller.rb +4 -0
  9. data/app/controllers/infotable/tables_controller.rb +299 -0
  10. data/app/helpers/infotable/application_helper.rb +5 -0
  11. data/app/helpers/infotable/tables_helper.rb +354 -0
  12. data/app/jobs/infotable/application_job.rb +4 -0
  13. data/app/mailers/infotable/application_mailer.rb +6 -0
  14. data/app/models/infotable/application_record.rb +5 -0
  15. data/app/views/infotable/tables/_column_visibility.html.erb +100 -0
  16. data/app/views/infotable/tables/_filter_drawer.html.erb +65 -0
  17. data/app/views/infotable/tables/_next_page_frame.html.erb +39 -0
  18. data/app/views/infotable/tables/_pagination.html.erb +101 -0
  19. data/app/views/infotable/tables/_side_panel.html.erb +63 -0
  20. data/app/views/infotable/tables/_table.html.erb +135 -0
  21. data/app/views/infotable/tables/_table_rows.html.erb +38 -0
  22. data/app/views/infotable/tables/filters/_boolean.html.erb +22 -0
  23. data/app/views/infotable/tables/filters/_date.html.erb +30 -0
  24. data/app/views/infotable/tables/filters/_number.html.erb +32 -0
  25. data/app/views/infotable/tables/filters/_select.html.erb +68 -0
  26. data/app/views/infotable/tables/filters/_text.html.erb +38 -0
  27. data/app/views/infotable/tables/show.html.erb +113 -0
  28. data/app/views/layouts/infotable/application.html.erb +15 -0
  29. data/config/importmap.rb +6 -0
  30. data/config/locales/en.yml +88 -0
  31. data/config/locales/pl.yml +88 -0
  32. data/config/routes.rb +6 -0
  33. data/lib/generators/infotable/install/install_generator.rb +565 -0
  34. data/lib/generators/infotable/install/templates/TAILWIND_CONFIG.md +131 -0
  35. data/lib/generators/infotable/install/templates/initializer.rb +26 -0
  36. data/lib/generators/infotable/install/templates/tailwind.config.js +284 -0
  37. data/lib/generators/infotable/table/table_generator.rb +95 -0
  38. data/lib/generators/infotable/table/templates/table.rb.tt +26 -0
  39. data/lib/infotable/base.rb +463 -0
  40. data/lib/infotable/batch_actions.rb +34 -0
  41. data/lib/infotable/configuration.rb +53 -0
  42. data/lib/infotable/engine.rb +51 -0
  43. data/lib/infotable/filter_processor.rb +326 -0
  44. data/lib/infotable/formatters/action_formatter.rb +141 -0
  45. data/lib/infotable/formatters/avatar_formatter.rb +49 -0
  46. data/lib/infotable/formatters/badge_formatter.rb +80 -0
  47. data/lib/infotable/formatters/base_formatter.rb +47 -0
  48. data/lib/infotable/formatters/boolean_formatter.rb +38 -0
  49. data/lib/infotable/formatters/currency_formatter.rb +22 -0
  50. data/lib/infotable/formatters/date_formatter.rb +58 -0
  51. data/lib/infotable/formatters/link_formatter.rb +48 -0
  52. data/lib/infotable/formatters/percent_formatter.rb +27 -0
  53. data/lib/infotable/search_processor.rb +42 -0
  54. data/lib/infotable/table_resolver.rb +45 -0
  55. data/lib/infotable/version.rb +3 -0
  56. data/lib/infotable.rb +54 -0
  57. data/lib/tasks/infotable.rake +55 -0
  58. metadata +216 -0
@@ -0,0 +1,921 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // Main table controller that coordinates table interactions
4
+ export default class extends Controller {
5
+ static values = {
6
+ name: String,
7
+ urlState: Boolean
8
+ }
9
+
10
+ static targets = ["exportButton", "exportButtonText", "selectAll", "rowCheckbox", "batchBar", "batchCount"]
11
+
12
+ // Target callbacks (rowCheckboxTargetConnected) can fire before connect(), so state lives here
13
+ initialize() {
14
+ this.selected = new Set()
15
+ this.selectionScope = null
16
+ }
17
+
18
+ connect() {
19
+ // Apply column order from localStorage first (must be before visibility)
20
+ this.applyColumnOrder()
21
+
22
+ // Apply column visibility from localStorage when table loads
23
+ this.applyColumnVisibility()
24
+
25
+ // Apply column pinning (sticky positioning)
26
+ this.applyColumnPinning()
27
+
28
+ // Update export link with current visible columns
29
+ this.updateExportLink()
30
+
31
+ // Fill the search box and the filter drawer from the page URL (the server already loaded the frame with it)
32
+ if (this.urlStateValue) this.applyUrlStateToControls()
33
+
34
+ // Listen for turbo frame loads to reapply order and visibility
35
+ this.boundApplyOrderAndVisibility = this.applyOrderAndVisibility.bind(this)
36
+ this.element.addEventListener("turbo:frame-load", this.boundApplyOrderAndVisibility)
37
+
38
+ // Listen for column visibility changes to update export link
39
+ this.boundUpdateExportLink = this.updateExportLink.bind(this)
40
+ document.addEventListener("infotable:columns-changed", this.boundUpdateExportLink)
41
+ document.addEventListener("infotable:filters-changed", this.boundUpdateExportLink)
42
+ }
43
+
44
+ disconnect() {
45
+ this.element.removeEventListener("turbo:frame-load", this.boundApplyOrderAndVisibility)
46
+ document.removeEventListener("infotable:columns-changed", this.boundUpdateExportLink)
47
+ document.removeEventListener("infotable:filters-changed", this.boundUpdateExportLink)
48
+ clearTimeout(this.searchTimeout)
49
+ }
50
+
51
+ // Apply both order and visibility (used for turbo frame loads)
52
+ applyOrderAndVisibility() {
53
+ this.applyColumnOrder()
54
+ this.applyColumnVisibility()
55
+ this.applyColumnPinning()
56
+ }
57
+
58
+ // Handle loading states
59
+ beforeFetchRequest(event) {
60
+ this.showLoading()
61
+ }
62
+
63
+ afterFetchResponse(event) {
64
+ this.hideLoading()
65
+ }
66
+
67
+ // Handle turbo frame load
68
+ handleFrameLoad(event) {
69
+ this.syncSelectionScope(event.target)
70
+ if (this.urlStateValue && event.target.id === `${this.nameValue}_content`) this.syncUrl(event.target)
71
+ }
72
+
73
+ // ---- Table state in the address bar ---------------------------------------
74
+ // The frame URL (`/infotable/tables/users_table/data?filters[..]&sort=..&page=2`) is mirrored in the
75
+ // page URL as `?users_table[filters][..]=..&users_table[page]=2`, one namespace per table. The
76
+ // server reads it back when it renders the page (infotable_initial_state). Back/forward go through
77
+ // Turbo's normal history handling, which re-renders the page from that URL.
78
+
79
+ // "filters[name][op]" -> "users_table[filters][name][op]"
80
+ toUrlKey(key) {
81
+ const [, head, rest] = key.match(/^([^\[]+)(.*)$/)
82
+ return `${this.nameValue}[${head}]${rest}`
83
+ }
84
+
85
+ // "users_table[filters][name][op]" -> "filters[name][op]" (nil when it belongs to another table)
86
+ fromUrlKey(key) {
87
+ const prefix = `${this.nameValue}[`
88
+ if (!key.startsWith(prefix)) return null
89
+ const [, head, rest] = key.slice(this.nameValue.length).match(/^\[([^\]]*)\](.*)$/) || []
90
+ return head ? head + rest : null
91
+ }
92
+
93
+ // This table's state from the page URL, as plain frame params
94
+ urlStateParams() {
95
+ const state = new URLSearchParams()
96
+ for (const [key, value] of new URLSearchParams(window.location.search)) {
97
+ const inner = this.fromUrlKey(key)
98
+ if (inner) state.append(inner, value)
99
+ }
100
+ return state
101
+ }
102
+
103
+ syncUrl(frame) {
104
+ if (!frame.src) return
105
+
106
+ const next = new URL(window.location.href)
107
+ for (const key of Array.from(next.searchParams.keys())) {
108
+ if (key.startsWith(`${this.nameValue}[`)) next.searchParams.delete(key)
109
+ }
110
+
111
+ const framePath = new URL(frame.src, window.location.href)
112
+ for (const [key, value] of framePath.searchParams) {
113
+ if (value === "" || key === "format" || key === "table_name") continue
114
+ if (key === "page" && value === "1") continue
115
+ next.searchParams.append(this.toUrlKey(key), value)
116
+ }
117
+
118
+ if (next.search === window.location.search) {
119
+ this.urlStateStarted = true
120
+ return
121
+ }
122
+
123
+ // The first change on a fresh page replaces the entry, later ones add entries for Back
124
+ const state = { turbo: { restorationIdentifier: crypto.randomUUID() } }
125
+ if (this.urlStateStarted) {
126
+ window.history.pushState(state, "", next)
127
+ } else {
128
+ window.history.replaceState(window.history.state, "", next)
129
+ }
130
+ this.urlStateStarted = true
131
+ }
132
+
133
+ applyUrlStateToControls() {
134
+ const state = this.urlStateParams()
135
+ if (state.size === 0) return
136
+
137
+ const search = this.element.querySelector('input[name="search"]')
138
+ if (search && state.has("search")) search.value = state.get("search")
139
+
140
+ const form = this.element.querySelector(`#filter-form-${CSS.escape(this.nameValue)}`)
141
+ if (!form) return
142
+
143
+ const names = new Set(Array.from(state.keys()).filter(key => key.startsWith("filters[")))
144
+ names.forEach(name => {
145
+ const values = state.getAll(name)
146
+ form.querySelectorAll(`[name="${CSS.escape(name)}"]`).forEach(control => {
147
+ if (control.tagName === "SELECT" && control.multiple) {
148
+ Array.from(control.options).forEach(option => { option.selected = values.includes(option.value) })
149
+ } else if (control.type === "checkbox" || control.type === "radio") {
150
+ control.checked = values.includes(control.value)
151
+ } else {
152
+ control.value = values[0]
153
+ }
154
+ })
155
+ })
156
+
157
+ // Hide the value box for operators such as "is empty"
158
+ // (the drawer controller may not be connected yet, so look it up on the next tick)
159
+ setTimeout(() => {
160
+ this.application.getControllerForElementAndIdentifier(this.element, "infotable--drawer")?.syncFilterOps()
161
+ }, 0)
162
+ }
163
+
164
+ // ---- Batch selection ------------------------------------------------------
165
+ // The selection survives page changes and infinite scroll. It is cleared when
166
+ // the rows it refers to change meaning: a different search, filter or sort.
167
+
168
+ syncSelectionScope(frame) {
169
+ if (!frame?.src) return
170
+
171
+ const params = new URL(frame.src, window.location.href).searchParams
172
+ params.delete("page")
173
+ params.delete("per_page")
174
+ params.delete("format")
175
+ params.sort()
176
+ const scope = params.toString()
177
+
178
+ if (this.selectionScope !== null && scope !== this.selectionScope) this.clearSelection()
179
+ this.selectionScope = scope
180
+ }
181
+
182
+ // Fires for rows added later (next page, infinite scroll) so they pick up the selection
183
+ rowCheckboxTargetConnected(checkbox) {
184
+ checkbox.checked = this.selected.has(checkbox.value)
185
+ this.renderSelection()
186
+ }
187
+
188
+ toggleRow(event) {
189
+ const checkbox = event.currentTarget
190
+ checkbox.checked ? this.selected.add(checkbox.value) : this.selected.delete(checkbox.value)
191
+ this.renderSelection()
192
+ }
193
+
194
+ toggleAll(event) {
195
+ const checked = event.currentTarget.checked
196
+ this.rowCheckboxTargets.forEach(checkbox => {
197
+ checkbox.checked = checked
198
+ checked ? this.selected.add(checkbox.value) : this.selected.delete(checkbox.value)
199
+ })
200
+ this.renderSelection()
201
+ }
202
+
203
+ clearSelection() {
204
+ this.selected.clear()
205
+ this.rowCheckboxTargets.forEach(checkbox => { checkbox.checked = false })
206
+ this.renderSelection()
207
+ }
208
+
209
+ renderSelection() {
210
+ const count = this.selected.size
211
+
212
+ if (this.hasBatchBarTarget) {
213
+ this.batchBarTarget.classList.toggle("hidden", count === 0)
214
+ this.batchBarTarget.classList.toggle("flex", count > 0)
215
+ }
216
+ if (this.hasBatchCountTarget) {
217
+ this.batchCountTarget.textContent = this.batchCountTarget.dataset.template.replace("__COUNT__", count)
218
+ }
219
+ if (this.hasSelectAllTarget) {
220
+ const boxes = this.rowCheckboxTargets
221
+ const checkedOnPage = boxes.filter(box => box.checked).length
222
+ this.selectAllTarget.checked = boxes.length > 0 && checkedOnPage === boxes.length
223
+ this.selectAllTarget.indeterminate = checkedOnPage > 0 && checkedOnPage < boxes.length
224
+ }
225
+
226
+ this.element.dispatchEvent(new CustomEvent("infotable:selection-changed", {
227
+ bubbles: true,
228
+ detail: { table: this.nameValue, ids: Array.from(this.selected) }
229
+ }))
230
+ }
231
+
232
+ // Sends the selected ids as ids[] to the action's URL. GET navigates; other verbs
233
+ // submit a form (with Rails' _method override and the CSRF token).
234
+ runBatchAction(event) {
235
+ const button = event.currentTarget
236
+ const ids = Array.from(this.selected)
237
+ if (ids.length === 0) return
238
+
239
+ const confirmText = button.dataset.batchConfirm
240
+ if (confirmText && !window.confirm(confirmText.replace("%{count}", ids.length))) return
241
+
242
+ const method = (button.dataset.batchMethod || "post").toLowerCase()
243
+ const url = button.dataset.batchUrl
244
+
245
+ if (method === "get") {
246
+ const target = new URL(url, window.location.href)
247
+ ids.forEach(id => target.searchParams.append("ids[]", id))
248
+ window.Turbo ? window.Turbo.visit(target.toString()) : window.location.assign(target.toString())
249
+ return
250
+ }
251
+
252
+ const form = document.createElement("form")
253
+ form.method = "post"
254
+ form.action = url
255
+ form.hidden = true
256
+
257
+ const field = (name, value) => {
258
+ const input = document.createElement("input")
259
+ input.type = "hidden"
260
+ input.name = name
261
+ input.value = value
262
+ form.appendChild(input)
263
+ }
264
+
265
+ const csrf = document.querySelector('meta[name="csrf-token"]')?.content
266
+ if (csrf) field("authenticity_token", csrf)
267
+ if (method !== "post") field("_method", method)
268
+ ids.forEach(id => field("ids[]", id))
269
+
270
+ document.body.appendChild(form)
271
+ form.requestSubmit()
272
+ form.remove()
273
+ }
274
+
275
+ showLoading() {
276
+ // Add loading indicator (to be enhanced in later phases)
277
+ this.element.style.opacity = "0.6"
278
+ }
279
+
280
+ hideLoading() {
281
+ this.element.style.opacity = "1"
282
+ }
283
+
284
+ // Shift+click on a sortable header adds/toggles the column in the multi-sort list
285
+ // (sort=name:asc,age:desc). A plain click keeps the link's single-column behaviour.
286
+ sortClick(event) {
287
+ if (!event.shiftKey) return
288
+
289
+ event.preventDefault()
290
+ const column = event.currentTarget.dataset.sortColumn
291
+ const frame = document.getElementById(`${this.nameValue}_content`)
292
+ if (!frame || !column) return
293
+
294
+ const url = new URL(frame.src || frame.getAttribute("src"), window.location.href)
295
+ const params = url.searchParams
296
+
297
+ let sorts = (params.get("sort") || "").split(",").filter(Boolean).map(item => item.split(":"))
298
+ if (sorts.length === 0 && params.get("sort_column")) {
299
+ sorts = [[params.get("sort_column"), params.get("sort_direction") || "asc"]]
300
+ }
301
+
302
+ const existing = sorts.find(([name]) => name === column)
303
+ if (existing) {
304
+ existing[1] = existing[1] === "asc" ? "desc" : "asc"
305
+ } else {
306
+ sorts.push([column, "asc"])
307
+ }
308
+
309
+ params.delete("sort_column")
310
+ params.delete("sort_direction")
311
+ params.set("sort", sorts.slice(0, 3).map(([name, dir]) => `${name}:${dir}`).join(","))
312
+ params.set("page", "1")
313
+ frame.src = url.toString()
314
+ }
315
+
316
+ // Debounced global search: reload the frame with ?search=, keeping sort/filters
317
+ search(event) {
318
+ clearTimeout(this.searchTimeout)
319
+ const value = event.currentTarget.value.trim()
320
+
321
+ this.searchTimeout = setTimeout(() => {
322
+ const frame = document.getElementById(`${this.nameValue}_content`)
323
+ if (!frame) return
324
+
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()
333
+
334
+ // Export link follows the frame's params
335
+ frame.addEventListener("turbo:frame-load", () => this.updateExportLink(), { once: true })
336
+ }, 300)
337
+ }
338
+
339
+ // Handle per-page change
340
+ changePerPage(event) {
341
+ const select = event.currentTarget
342
+ const perPage = select.value
343
+ const tableName = select.dataset.tableName
344
+
345
+ // Build new URL with updated per_page parameter (keeps sort and filters)
346
+ const params = new URLSearchParams(select.dataset.currentQuery || '')
347
+ params.set('per_page', perPage)
348
+ params.set('page', '1') // Reset to first page when changing per_page
349
+
350
+ // Get the table frame and update its src
351
+ const tableFrame = document.getElementById(`${tableName}_content`)
352
+ if (tableFrame) {
353
+ const baseUrl = tableFrame.src.split('?')[0]
354
+ tableFrame.src = `${baseUrl}?${params.toString()}`
355
+ }
356
+ }
357
+
358
+ // Apply column order from localStorage
359
+ applyColumnOrder() {
360
+ const tableName = this.nameValue
361
+ const orderKey = `infotable_column_order_${tableName}`
362
+
363
+ try {
364
+ // Try to find table in the wrapper or in turbo-frame
365
+ let table = this.element.querySelector("table")
366
+ if (!table) {
367
+ const turboFrame = document.getElementById(`${tableName}_content`)
368
+ if (turboFrame) {
369
+ table = turboFrame.querySelector("table")
370
+ }
371
+ }
372
+
373
+ if (!table) {
374
+ return
375
+ }
376
+
377
+ const headerRow = table.querySelector("thead tr")
378
+ if (!headerRow) return
379
+
380
+ const headers = Array.from(headerRow.querySelectorAll("th[data-column-name]"))
381
+ if (headers.length === 0) return
382
+
383
+ // Get default schema from current DOM order
384
+ const defaultSchema = headers.map(h => h.dataset.columnName)
385
+
386
+ // Load stored order from localStorage
387
+ const storedOrder = this.loadColumnOrder(tableName)
388
+ if (!storedOrder) return
389
+
390
+ // Validate stored order against default schema
391
+ if (!this.isSchemaValid(storedOrder, defaultSchema)) {
392
+ console.warn(`Schema mismatch for table ${tableName}, clearing stored order`)
393
+ this.clearColumnOrder(tableName)
394
+ return
395
+ }
396
+
397
+ // Apply the order
398
+ const headerMap = new Map()
399
+ headers.forEach(header => {
400
+ headerMap.set(header.dataset.columnName, header)
401
+ })
402
+
403
+ // Reorder headers
404
+ storedOrder.forEach(columnName => {
405
+ const header = headerMap.get(columnName)
406
+ if (header) {
407
+ headerRow.appendChild(header)
408
+ }
409
+ })
410
+
411
+ // Reorder cells in each row
412
+ const rows = table.querySelectorAll("tbody tr")
413
+ rows.forEach(row => {
414
+ const cells = Array.from(row.querySelectorAll("td[data-column-name]"))
415
+ const cellMap = new Map()
416
+
417
+ cells.forEach(cell => {
418
+ cellMap.set(cell.dataset.columnName, cell)
419
+ })
420
+
421
+ storedOrder.forEach(columnName => {
422
+ const cell = cellMap.get(columnName)
423
+ if (cell) {
424
+ row.appendChild(cell)
425
+ }
426
+ })
427
+ })
428
+ } catch (e) {
429
+ console.warn("Failed to apply column order", e)
430
+ }
431
+ }
432
+
433
+ // Load column order from localStorage
434
+ loadColumnOrder(tableName) {
435
+ const key = `infotable_column_order_${tableName}`
436
+ try {
437
+ const stored = localStorage.getItem(key)
438
+ return stored ? JSON.parse(stored) : null
439
+ } catch (e) {
440
+ console.warn("Failed to parse column order from localStorage", e)
441
+ return null
442
+ }
443
+ }
444
+
445
+ // Validate schema
446
+ isSchemaValid(storedOrder, defaultSchema) {
447
+ if (storedOrder.length !== defaultSchema.length) {
448
+ return false
449
+ }
450
+
451
+ const storedSet = new Set(storedOrder)
452
+ const defaultSet = new Set(defaultSchema)
453
+
454
+ if (storedSet.size !== defaultSet.size) {
455
+ return false
456
+ }
457
+
458
+ for (const col of storedOrder) {
459
+ if (!defaultSet.has(col)) {
460
+ return false
461
+ }
462
+ }
463
+
464
+ return true
465
+ }
466
+
467
+ // Clear column order
468
+ clearColumnOrder(tableName) {
469
+ const key = `infotable_column_order_${tableName}`
470
+ localStorage.removeItem(key)
471
+ }
472
+
473
+ // Apply column visibility from localStorage or use defaults
474
+ applyColumnVisibility() {
475
+ const tableName = this.nameValue
476
+ const key = `infotable_columns_${tableName}`
477
+
478
+ try {
479
+ // Try to find table in the wrapper or in turbo-frame
480
+ let table = this.element.querySelector("table")
481
+ if (!table) {
482
+ // Table might be inside turbo-frame
483
+ const turboFrame = document.getElementById(`${tableName}_content`)
484
+ if (turboFrame) {
485
+ table = turboFrame.querySelector("table")
486
+ }
487
+ }
488
+
489
+ if (!table) {
490
+ console.warn(`Table not found for: ${tableName}`)
491
+ return
492
+ }
493
+
494
+ const headers = table.querySelectorAll("thead th[data-column-name]")
495
+ const rows = table.querySelectorAll("tbody tr")
496
+
497
+ if (headers.length === 0) {
498
+ console.warn(`No column headers found for table: ${tableName}`)
499
+ return
500
+ }
501
+
502
+ // Get saved preferences from localStorage
503
+ let visibility = null
504
+ const stored = localStorage.getItem(key)
505
+ if (stored) {
506
+ visibility = JSON.parse(stored)
507
+ }
508
+
509
+ headers.forEach((header) => {
510
+ const columnName = header.dataset.columnName
511
+ const defaultVisible = header.dataset.defaultVisible === "true"
512
+
513
+ // Determine if column should be visible
514
+ let shouldShow
515
+ if (visibility && visibility.hasOwnProperty(columnName)) {
516
+ // Use saved preference
517
+ shouldShow = visibility[columnName]
518
+ } else {
519
+ // Use default visibility
520
+ shouldShow = defaultVisible
521
+ }
522
+
523
+ // Apply visibility
524
+ header.style.display = shouldShow ? "" : "none"
525
+
526
+ // Hide/show corresponding cells in all rows
527
+ rows.forEach(row => {
528
+ const cells = row.querySelectorAll("td[data-column-name]")
529
+ const cell = Array.from(cells).find(c => c.dataset.columnName === columnName)
530
+ if (cell) {
531
+ cell.style.display = shouldShow ? "" : "none"
532
+ }
533
+ })
534
+ })
535
+ } catch (e) {
536
+ console.warn("Failed to apply column visibility", e)
537
+ }
538
+
539
+ // Update export link after applying visibility
540
+ this.updateExportLink()
541
+ }
542
+
543
+ // Update export link with current visible columns and filters
544
+ updateExportLink() {
545
+ const tableName = this.nameValue
546
+ const exportLink = this.element.querySelector('a[href*="/export"]')
547
+
548
+ if (!exportLink) return
549
+
550
+ // Get current visible columns from localStorage
551
+ const key = `infotable_columns_${tableName}`
552
+ let visibility = {}
553
+ try {
554
+ const stored = localStorage.getItem(key)
555
+ if (stored) {
556
+ visibility = JSON.parse(stored)
557
+ }
558
+ } catch (e) {
559
+ console.warn("Failed to parse column visibility from localStorage", e)
560
+ }
561
+
562
+ // Get all columns and determine which are visible
563
+ // Try to find table in wrapper or turbo-frame
564
+ let table = this.element.querySelector("table")
565
+ if (!table) {
566
+ const turboFrame = document.getElementById(`${tableName}_content`)
567
+ if (turboFrame) {
568
+ table = turboFrame.querySelector("table")
569
+ }
570
+ }
571
+
572
+ if (!table) {
573
+ // If table not found yet, use default visible columns from table class definition
574
+ // We'll need to get this from the initial link or use all columns
575
+ return
576
+ }
577
+
578
+ const headers = table.querySelectorAll("thead th[data-column-name]")
579
+ const visibleColumns = []
580
+
581
+ headers.forEach((header) => {
582
+ const columnName = header.dataset.columnName
583
+ const defaultVisible = header.dataset.defaultVisible === "true"
584
+
585
+ // Determine if column should be visible
586
+ let shouldShow
587
+ if (visibility && visibility.hasOwnProperty(columnName)) {
588
+ shouldShow = visibility[columnName]
589
+ } else {
590
+ shouldShow = defaultVisible
591
+ }
592
+
593
+ if (shouldShow) {
594
+ visibleColumns.push(columnName)
595
+ }
596
+ })
597
+
598
+ // Current filters/sort live in the turbo-frame src (drawer and sort links update it,
599
+ // window.location is not touched)
600
+ const frame = document.getElementById(`${tableName}_content`)
601
+ const currentUrl = new URL(frame?.src || window.location.href, window.location.href)
602
+ const params = new URLSearchParams()
603
+
604
+ // Add visible columns
605
+ if (visibleColumns.length > 0) {
606
+ params.set('visible_columns', visibleColumns.join(','))
607
+ }
608
+
609
+ // Add filters from current URL (Rails nested params format: filters[column][op], filters[column][value])
610
+ currentUrl.searchParams.forEach((value, key) => {
611
+ if (key.startsWith('filters[')) {
612
+ params.set(key, value)
613
+ } else if (key === 'sort_column' || key === 'sort_direction' || key === 'sort' || key === 'search') {
614
+ params.set(key, value)
615
+ }
616
+ })
617
+
618
+ // Update export link
619
+ const baseUrl = exportLink.href.split('?')[0]
620
+ const queryString = params.toString()
621
+ exportLink.href = queryString ? `${baseUrl}?${queryString}` : baseUrl
622
+ }
623
+
624
+ // Handle CSV export click - show brief loading state
625
+ exportCsv(event) {
626
+ const button = event.currentTarget
627
+ const buttonText = button.querySelector('[data-infotable--table-target="exportButtonText"]')
628
+
629
+ if (buttonText) {
630
+ // Show loading state immediately
631
+ button.classList.add('opacity-75', 'cursor-wait')
632
+ const originalText = buttonText.textContent
633
+ const exportingText = button.dataset.exportingText || 'Exporting...'
634
+ buttonText.textContent = exportingText
635
+
636
+ // Since CSV downloads are streamed and fast, reset quickly
637
+ // The browser handles the download, we just provide visual feedback
638
+ setTimeout(() => {
639
+ button.classList.remove('opacity-75', 'cursor-wait')
640
+ buttonText.textContent = originalText
641
+ }, 500) // Brief feedback since streaming responses are fast
642
+ }
643
+
644
+ // Allow the default link behavior to proceed (download CSV)
645
+ // The data-turbo="false" ensures Turbo doesn't intercept this
646
+ }
647
+
648
+ // Handle row click for clickable rows
649
+ handleRowClick(event) {
650
+ const row = event.currentTarget
651
+ const url = row.dataset.rowLink
652
+
653
+ if (!url) return
654
+
655
+ // Don't navigate if clicking on interactive elements
656
+ const target = event.target
657
+ const tagName = target.tagName.toLowerCase()
658
+
659
+ // Ignore clicks on links, buttons, inputs, selects, etc.
660
+ if (tagName === "a" || tagName === "button" || tagName === "input" ||
661
+ tagName === "select" || tagName === "textarea") {
662
+ return
663
+ }
664
+
665
+ // Ignore clicks on elements inside buttons or links
666
+ if (target.closest("a") || target.closest("button")) {
667
+ return
668
+ }
669
+
670
+ // Navigate to the URL
671
+ // Check if meta key (Cmd on Mac, Ctrl on Windows) is pressed for new tab
672
+ if (event.metaKey || event.ctrlKey) {
673
+ window.open(url, "_blank")
674
+ } else {
675
+ window.location.href = url
676
+ }
677
+ }
678
+
679
+ // Apply column pinning (sticky positioning) from localStorage
680
+ applyColumnPinning() {
681
+ const tableName = this.nameValue
682
+ const pinnedKey = `infotable_column_pinned_${tableName}`
683
+
684
+ try {
685
+ // Get pinned columns from localStorage
686
+ let pinnedData = {}
687
+ const stored = localStorage.getItem(pinnedKey)
688
+ if (stored) {
689
+ pinnedData = JSON.parse(stored)
690
+ }
691
+
692
+ // Try to find table in the wrapper or in turbo-frame
693
+ let table = this.element.querySelector("table")
694
+ if (!table) {
695
+ const turboFrame = document.getElementById(`${tableName}_content`)
696
+ if (turboFrame) {
697
+ table = turboFrame.querySelector("table")
698
+ }
699
+ }
700
+
701
+ if (!table) {
702
+ console.warn(`Table not found for pinning: ${tableName}`)
703
+ return
704
+ }
705
+
706
+ const headers = Array.from(table.querySelectorAll("thead th[data-column-name]"))
707
+
708
+ // Columns the user never pinned/unpinned fall back to the table's `pinned:` default
709
+ headers.forEach(header => {
710
+ const name = header.dataset.columnName
711
+ if (header.dataset.defaultPinned && !Object.prototype.hasOwnProperty.call(pinnedData, name)) {
712
+ pinnedData[name] = header.dataset.defaultPinned
713
+ }
714
+ })
715
+ const rows = table.querySelectorAll("tbody tr")
716
+
717
+
718
+ // Calculate positions for pinned columns
719
+ let leftOffset = 0
720
+ let rightOffset = 0
721
+
722
+ // First pass: reset all columns
723
+ headers.forEach(header => {
724
+ header.style.position = ''
725
+ header.style.left = ''
726
+ header.style.right = ''
727
+ header.style.zIndex = ''
728
+ header.style.removeProperty('background')
729
+ header.style.removeProperty('background-color')
730
+ header.style.removeProperty('background-image')
731
+ header.style.removeProperty('background-position')
732
+ header.style.removeProperty('background-size')
733
+ header.style.removeProperty('background-repeat')
734
+ header.style.removeProperty('background-attachment')
735
+ header.style.removeProperty('opacity')
736
+ header.style.backgroundClip = ''
737
+ 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]")
739
+ })
740
+
741
+ rows.forEach(row => {
742
+ const cells = row.querySelectorAll("td[data-column-name]")
743
+ cells.forEach(cell => {
744
+ cell.style.position = ''
745
+ cell.style.left = ''
746
+ cell.style.right = ''
747
+ cell.style.zIndex = ''
748
+ cell.style.removeProperty('background')
749
+ cell.style.removeProperty('background-color')
750
+ cell.style.removeProperty('background-image')
751
+ cell.style.removeProperty('background-position')
752
+ cell.style.removeProperty('background-size')
753
+ cell.style.removeProperty('background-repeat')
754
+ cell.style.removeProperty('background-attachment')
755
+ cell.style.removeProperty('opacity')
756
+ cell.style.backgroundClip = ''
757
+ 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]")
759
+ })
760
+ })
761
+
762
+ // Find last left-pinned and first right-pinned columns
763
+ let lastLeftPinned = null
764
+ let firstRightPinned = null
765
+
766
+ headers.forEach(header => {
767
+ const columnName = header.dataset.columnName
768
+ const pinned = pinnedData[columnName]
769
+ if (pinned === 'left') {
770
+ lastLeftPinned = columnName
771
+ }
772
+ if (pinned === 'right' && !firstRightPinned) {
773
+ firstRightPinned = columnName
774
+ }
775
+ })
776
+
777
+ // Apply sticky positioning for pinned columns
778
+ headers.forEach(header => {
779
+ const columnName = header.dataset.columnName
780
+ const pinned = pinnedData[columnName]
781
+
782
+ if (pinned === 'left') {
783
+ // Get existing inline styles
784
+ const existingStyles = header.getAttribute('style') || ''
785
+
786
+ // Build comprehensive style string with all !important flags
787
+ const newStyles = `
788
+ position: sticky !important;
789
+ top: 0px !important;
790
+ left: ${leftOffset}px !important;
791
+ z-index: 30 !important;
792
+ background: #f9fafb !important;
793
+ background-color: #f9fafb !important;
794
+ background-image: none !important;
795
+ background-position: initial !important;
796
+ background-size: initial !important;
797
+ background-repeat: initial !important;
798
+ background-attachment: initial !important;
799
+ opacity: 1 !important;
800
+ background-clip: padding-box !important;
801
+ box-shadow: 2px 0 4px -2px rgba(0, 0, 0, 0.1) !important;
802
+ ${existingStyles}
803
+ `.replace(/\s+/g, ' ').trim()
804
+
805
+ // Set all styles at once via setAttribute (most aggressive method)
806
+ header.setAttribute('style', newStyles)
807
+ header.classList.add('infotable-pinned-left', '!bg-gray-50')
808
+
809
+ // Mark last left-pinned column
810
+ if (columnName === lastLeftPinned) {
811
+ header.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]")
812
+ }
813
+
814
+ // Apply to cells in all rows
815
+ rows.forEach(row => {
816
+ const cell = row.querySelector(`td[data-column-name="${columnName}"]`)
817
+ if (cell) {
818
+ // Get existing inline styles
819
+ const existingStyles = cell.getAttribute('style') || ''
820
+
821
+ // Build comprehensive style string
822
+ const newStyles = `
823
+ position: sticky !important;
824
+ left: ${leftOffset}px !important;
825
+ z-index: 15 !important;
826
+ background: #ffffff !important;
827
+ background-color: #ffffff !important;
828
+ background-image: none !important;
829
+ background-position: initial !important;
830
+ background-size: initial !important;
831
+ background-repeat: initial !important;
832
+ background-attachment: initial !important;
833
+ opacity: 1 !important;
834
+ background-clip: padding-box !important;
835
+ box-shadow: 2px 0 4px -2px rgba(0, 0, 0, 0.1) !important;
836
+ ${existingStyles}
837
+ `.replace(/\s+/g, ' ').trim()
838
+
839
+ cell.setAttribute('style', newStyles)
840
+ cell.classList.add('infotable-pinned-left', '!bg-white')
841
+
842
+ if (columnName === lastLeftPinned) {
843
+ 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]")
844
+ }
845
+ }
846
+ })
847
+
848
+ leftOffset += header.offsetWidth
849
+ } else if (pinned === 'right') {
850
+ // Get existing inline styles
851
+ const existingStyles = header.getAttribute('style') || ''
852
+
853
+ // Build comprehensive style string
854
+ const newStyles = `
855
+ position: sticky !important;
856
+ top: 0px !important;
857
+ right: ${rightOffset}px !important;
858
+ z-index: 30 !important;
859
+ background: #f9fafb !important;
860
+ background-color: #f9fafb !important;
861
+ background-image: none !important;
862
+ background-position: initial !important;
863
+ background-size: initial !important;
864
+ background-repeat: initial !important;
865
+ background-attachment: initial !important;
866
+ opacity: 1 !important;
867
+ background-clip: padding-box !important;
868
+ box-shadow: -2px 0 4px -2px rgba(0, 0, 0, 0.1) !important;
869
+ ${existingStyles}
870
+ `.replace(/\s+/g, ' ').trim()
871
+
872
+ header.setAttribute('style', newStyles)
873
+ header.classList.add('infotable-pinned-right', '!bg-gray-50')
874
+
875
+ // Mark first right-pinned column
876
+ if (columnName === firstRightPinned) {
877
+ header.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]")
878
+ }
879
+
880
+ // Apply to cells in all rows
881
+ rows.forEach(row => {
882
+ const cell = row.querySelector(`td[data-column-name="${columnName}"]`)
883
+ if (cell) {
884
+ // Get existing inline styles
885
+ const existingStyles = cell.getAttribute('style') || ''
886
+
887
+ // Build comprehensive style string
888
+ const newStyles = `
889
+ position: sticky !important;
890
+ right: ${rightOffset}px !important;
891
+ z-index: 15 !important;
892
+ background: #ffffff !important;
893
+ background-color: #ffffff !important;
894
+ background-image: none !important;
895
+ background-position: initial !important;
896
+ background-size: initial !important;
897
+ background-repeat: initial !important;
898
+ background-attachment: initial !important;
899
+ opacity: 1 !important;
900
+ background-clip: padding-box !important;
901
+ box-shadow: -2px 0 4px -2px rgba(0, 0, 0, 0.1) !important;
902
+ ${existingStyles}
903
+ `.replace(/\s+/g, ' ').trim()
904
+
905
+ cell.setAttribute('style', newStyles)
906
+ cell.classList.add('infotable-pinned-right', '!bg-white')
907
+
908
+ if (columnName === firstRightPinned) {
909
+ 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]")
910
+ }
911
+ }
912
+ })
913
+
914
+ rightOffset += header.offsetWidth
915
+ }
916
+ })
917
+ } catch (e) {
918
+ console.warn("Failed to apply column pinning", e)
919
+ }
920
+ }
921
+ }