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,1412 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+ import Sortable from "sortablejs"
3
+
4
+ // Drawer controller for the filter drawer and column visibility
5
+ // Handles opening/closing the right-side drawer with smooth animations
6
+ export default class extends Controller {
7
+ static targets = ["drawer", "content", "tabButton", "tabContent", "panelTitle", "columnBadge", "columnList"]
8
+ static values = {
9
+ open: { type: Boolean, default: false },
10
+ currentTab: { type: String, default: "filters" }
11
+ }
12
+
13
+ connect() {
14
+ // Close drawer on escape key
15
+ this.boundCloseOnEscape = this.closeOnEscape.bind(this)
16
+ document.addEventListener("keydown", this.boundCloseOnEscape)
17
+
18
+ // Close drawer when clicking outside
19
+ this.boundCloseOnClickOutside = this.closeOnClickOutside.bind(this)
20
+
21
+ // Determine default tab based on which tab content is visible by default
22
+ // If filters tab doesn't exist, default to columns tab
23
+ const hasFiltersTab = this.tabContentTargets.some(content => content.dataset.tab === "filters")
24
+ if (!hasFiltersTab && this.currentTabValue === "filters") {
25
+ this.currentTabValue = "columns"
26
+ }
27
+
28
+ // Initialize to show appropriate tab by default
29
+ this.switchTabTo(this.currentTabValue)
30
+
31
+ // Initialize debounced filter submission
32
+ this.debounceTimer = null
33
+ this.debounceDelay = 500 // 500ms delay
34
+
35
+ // Set up debounced filter inputs
36
+ this.setupDebouncedFilters()
37
+
38
+ // Initialize Sortable for column ordering
39
+ this.initializeSortable()
40
+ }
41
+
42
+ openValueChanged() {
43
+ if (this.openValue) {
44
+ this.showDrawer()
45
+ // Show appropriate tab by default when opening
46
+ if (!this.currentTabValue) {
47
+ const hasFiltersTab = this.tabContentTargets.some(content => content.dataset.tab === "filters")
48
+ this.currentTabValue = hasFiltersTab ? "filters" : "columns"
49
+ } else {
50
+ this.switchTabTo(this.currentTabValue)
51
+ }
52
+ // Sync checkboxes and order with localStorage when drawer opens
53
+ if (this.currentTabValue === "columns") {
54
+ this.syncColumnCheckboxes()
55
+ this.syncColumnListOrder()
56
+ } else {
57
+ // Update badge even if not on columns tab, so it's ready when switching
58
+ setTimeout(() => {
59
+ this.updateAllColumnBadges()
60
+ }, 50)
61
+ }
62
+ // Setup debounced filters when drawer opens (in case form is rendered dynamically)
63
+ setTimeout(() => {
64
+ this.setupDebouncedFilters()
65
+ }, 50)
66
+ // Add click outside listener when drawer opens
67
+ // Use setTimeout to avoid immediate closure from the click that opened it
68
+ setTimeout(() => {
69
+ document.addEventListener("click", this.boundCloseOnClickOutside)
70
+ }, 100)
71
+ } else {
72
+ this.hideDrawer()
73
+ // Reset to appropriate default tab when closing
74
+ const hasFiltersTab = this.tabContentTargets.some(content => content.dataset.tab === "filters")
75
+ this.currentTabValue = hasFiltersTab ? "filters" : "columns"
76
+ // Remove click outside listener when drawer closes
77
+ document.removeEventListener("click", this.boundCloseOnClickOutside)
78
+ }
79
+ }
80
+
81
+ currentTabValueChanged() {
82
+ if (this.currentTabValue) {
83
+ this.switchTabTo(this.currentTabValue)
84
+ }
85
+ }
86
+
87
+ disconnect() {
88
+ document.removeEventListener("keydown", this.boundCloseOnEscape)
89
+ document.removeEventListener("click", this.boundCloseOnClickOutside)
90
+
91
+ // Clear debounce timer on disconnect
92
+ if (this.debounceTimer) {
93
+ clearTimeout(this.debounceTimer)
94
+ }
95
+
96
+ // Remove filter input listeners
97
+ const form = this.contentTarget?.querySelector("form")
98
+ if (form && this.boundHandleFilterInput) {
99
+ form.removeEventListener('input', this.boundHandleFilterInput, true)
100
+ form.removeEventListener('change', this.boundHandleFilterInput, true)
101
+ }
102
+
103
+ // Destroy Sortable instances
104
+ if (this.sortableInstances) {
105
+ this.sortableInstances.forEach(sortable => sortable.destroy())
106
+ this.sortableInstances.clear()
107
+ }
108
+ }
109
+
110
+ toggle() {
111
+ this.openValue = !this.openValue
112
+ }
113
+
114
+ open() {
115
+ // Default to appropriate tab when opening
116
+ if (!this.currentTabValue) {
117
+ const hasFiltersTab = this.tabContentTargets.some(content => content.dataset.tab === "filters")
118
+ this.currentTabValue = hasFiltersTab ? "filters" : "columns"
119
+ }
120
+ this.openValue = true
121
+ }
122
+
123
+ close() {
124
+ this.openValue = false
125
+ }
126
+
127
+ // Open drawer with columns tab (kept for backward compatibility)
128
+ openColumns(event) {
129
+ if (event) {
130
+ event.preventDefault()
131
+ event.stopPropagation()
132
+ }
133
+ this.currentTabValue = "columns"
134
+ this.openValue = true
135
+ }
136
+
137
+ // Open drawer with filters tab (kept for backward compatibility)
138
+ openFilters(event) {
139
+ if (event) {
140
+ event.preventDefault()
141
+ event.stopPropagation()
142
+ }
143
+ this.currentTabValue = "filters"
144
+ this.openValue = true
145
+ }
146
+
147
+
148
+
149
+ showDrawer() {
150
+ // Slide in drawer from its side using inline transform
151
+ this.previouslyFocused = document.activeElement
152
+ this.drawerTarget.inert = false
153
+ this.drawerTarget.removeAttribute("aria-hidden")
154
+ this.drawerTarget.style.transform = "translateX(0)"
155
+ this.drawerTarget.querySelector("input:not([type=hidden]), select, button")?.focus({ preventScroll: true })
156
+ }
157
+
158
+ hideDrawer() {
159
+ this.drawerTarget.inert = true
160
+ this.drawerTarget.setAttribute("aria-hidden", "true")
161
+ if (this.previouslyFocused?.isConnected) this.previouslyFocused.focus({ preventScroll: true })
162
+ this.previouslyFocused = null
163
+
164
+ // Slide out drawer to its side (data-drawer-side) using inline transform
165
+ this.drawerTarget.style.transform = this.drawerTarget.dataset.drawerSide === "left" ? "translateX(-100%)" : "translateX(100%)"
166
+ }
167
+
168
+ closeOnEscape(event) {
169
+ if (event.key === "Escape" && this.openValue) {
170
+ this.close()
171
+ }
172
+ }
173
+
174
+ closeOnClickOutside(event) {
175
+ // Don't close if drawer is not open
176
+ if (!this.openValue) {
177
+ return
178
+ }
179
+
180
+ // Don't close if drawer target doesn't exist
181
+ if (!this.hasDrawerTarget || !this.drawerTarget) {
182
+ return
183
+ }
184
+
185
+ // Don't close if click is inside the drawer
186
+ if (this.drawerTarget.contains(event.target)) {
187
+ return
188
+ }
189
+
190
+ // Don't close if clicking on buttons that open the drawer
191
+ const clickedElement = event.target.closest('button[data-action*="infotable--drawer#open"]')
192
+ if (clickedElement) {
193
+ return
194
+ }
195
+
196
+ // Don't close if clicking on elements inside buttons that open the drawer (like SVG icons)
197
+ const buttonParent = event.target.closest('button')
198
+ if (buttonParent && buttonParent.hasAttribute('data-action') &&
199
+ buttonParent.getAttribute('data-action').includes('infotable--drawer#open')) {
200
+ return
201
+ }
202
+
203
+ // Close the drawer when clicking outside (inside or outside the table)
204
+ event.stopPropagation()
205
+ this.close()
206
+ }
207
+
208
+
209
+ // Handle filter form submission
210
+ submitFilters(event) {
211
+ if (event) {
212
+ event.preventDefault()
213
+ }
214
+ const form = event?.target || this.contentTarget.querySelector("form")
215
+ if (!form) return
216
+
217
+ const formData = new FormData(form)
218
+
219
+ // Build URL with filter params
220
+ const params = new URLSearchParams(formData)
221
+
222
+ // Update the table via Turbo Frame
223
+ const tableFrame = document.getElementById(form.dataset.tableFrame)
224
+
225
+ // Keep search and sort from the current frame URL
226
+ if (tableFrame?.src) {
227
+ const current = new URL(tableFrame.src, window.location.href).searchParams
228
+ for (const key of ["search", "sort", "sort_column", "sort_direction"]) {
229
+ if (current.has(key) && !params.has(key)) params.set(key, current.get(key))
230
+ }
231
+ }
232
+ const url = form.action + "?" + params.toString()
233
+
234
+ if (tableFrame) {
235
+ tableFrame.src = url
236
+
237
+ // Dispatch event to update export link after frame loads
238
+ tableFrame.addEventListener("turbo:frame-load", () => {
239
+ const tableName = form.action.match(/tables\/([^\/]+)/)?.[1]
240
+ if (tableName) {
241
+ document.dispatchEvent(new CustomEvent("infotable:filters-changed", {
242
+ detail: { tableName }
243
+ }))
244
+ }
245
+ }, { once: true })
246
+ }
247
+ }
248
+
249
+ // Setup debounced filter inputs using event delegation
250
+ setupDebouncedFilters() {
251
+ // Find form in the drawer content (might be in filters tab)
252
+ const form = this.contentTarget?.querySelector("form")
253
+ if (!form) return
254
+
255
+ // Check if autofilter is enabled (default: true)
256
+ // HTML data attributes are strings, so check for string 'false'
257
+ const autofilterValue = form.dataset.autofilter
258
+ // If attribute is missing or is the string 'true', enable autofilter
259
+ // Only disable if explicitly set to 'false'
260
+ const autofilterEnabled = autofilterValue !== 'false'
261
+
262
+ // Remove existing listeners if any (to avoid duplicates)
263
+ if (this.boundHandleFilterInput) {
264
+ form.removeEventListener('input', this.boundHandleFilterInput, true)
265
+ form.removeEventListener('change', this.boundHandleFilterInput, true)
266
+ }
267
+
268
+ // Only setup debounced listeners if autofilter is enabled
269
+ if (!autofilterEnabled) {
270
+ return
271
+ }
272
+
273
+ // Create bound handler for event delegation
274
+ this.boundHandleFilterInput = (event) => {
275
+ const target = event.target
276
+ // Only handle filter inputs (not hidden inputs, buttons, etc.)
277
+ if (target.tagName === 'INPUT' && target.type === 'hidden') return
278
+ if (target.tagName === 'BUTTON') return
279
+
280
+ // Check if it's a filter input
281
+ const isFilterInput = (
282
+ (target.tagName === 'INPUT' && ['text', 'number', 'date'].includes(target.type)) ||
283
+ target.tagName === 'SELECT'
284
+ )
285
+
286
+ if (isFilterInput) {
287
+ this.debouncedSubmitFilters()
288
+ }
289
+ }
290
+
291
+ // Use event delegation on the form for both input and change events
292
+ // This handles all filter inputs without needing to attach individual listeners
293
+ form.addEventListener('input', this.boundHandleFilterInput, true)
294
+ form.addEventListener('change', this.boundHandleFilterInput, true)
295
+ }
296
+
297
+ // Debounced filter submission
298
+ debouncedSubmitFilters() {
299
+ // Clear existing timer
300
+ if (this.debounceTimer) {
301
+ clearTimeout(this.debounceTimer)
302
+ }
303
+
304
+ // Set new timer
305
+ this.debounceTimer = setTimeout(() => {
306
+ this.submitFilters(null)
307
+ }, this.debounceDelay)
308
+ }
309
+
310
+ // Clear all filters
311
+ clearFilters(event) {
312
+ event.preventDefault()
313
+ const form = this.contentTarget.querySelector("form")
314
+ if (form) {
315
+ // Clear debounce timer
316
+ if (this.debounceTimer) {
317
+ clearTimeout(this.debounceTimer)
318
+ this.debounceTimer = null
319
+ }
320
+
321
+ // Reset all inputs except hidden ones (sort, per_page)
322
+ const inputs = form.querySelectorAll('input:not([type="hidden"]), select, textarea')
323
+ inputs.forEach(input => {
324
+ if (input.type === 'checkbox' || input.type === 'radio') {
325
+ input.checked = false
326
+ } else {
327
+ input.value = ''
328
+ }
329
+ })
330
+
331
+ this.syncFilterOps()
332
+
333
+ // Submit immediately (no debounce for clear)
334
+ this.submitFilters(null)
335
+ }
336
+ }
337
+
338
+ // Clear a single filter
339
+ clearSingleFilter(event) {
340
+ event.preventDefault()
341
+ const button = event.currentTarget
342
+ const filterName = button.dataset.filterName
343
+ const filterType = button.dataset.filterType
344
+ const form = this.contentTarget.querySelector("form")
345
+
346
+ if (!form || !filterName) return
347
+
348
+ // Controls are found by name inside this table's form, so ids may repeat across tables
349
+ const field = (suffix) => form.querySelector(
350
+ `[name="filters[${filterName}][${suffix}]"], [name="filters[${filterName}][${suffix}][]"]`
351
+ )
352
+
353
+ // Clear debounce timer
354
+ if (this.debounceTimer) {
355
+ clearTimeout(this.debounceTimer)
356
+ this.debounceTimer = null
357
+ }
358
+
359
+ // Clear based on filter type
360
+ switch (filterType) {
361
+ case 'text':
362
+ // Clear both operation and value
363
+ const textOp = field('op')
364
+ const textValue = field('value')
365
+ if (textOp) textOp.value = 'contains'
366
+ if (textValue) textValue.value = ''
367
+ break
368
+
369
+ case 'number':
370
+ case 'date':
371
+ // Clear both from and to fields
372
+ const fromInput = field('from')
373
+ const toInput = field('to')
374
+ if (fromInput) fromInput.value = ''
375
+ if (toInput) toInput.value = ''
376
+ break
377
+
378
+ case 'select':
379
+ // Clear select value (single or multi)
380
+ const selectInput = field('value')
381
+ if (selectInput) {
382
+ if (selectInput.multiple) {
383
+ // Clear all selections in multi-select
384
+ Array.from(selectInput.options).forEach(option => {
385
+ option.selected = false
386
+ })
387
+ } else {
388
+ // Reset to empty/default option
389
+ selectInput.value = ''
390
+ }
391
+ }
392
+ break
393
+
394
+ case 'boolean':
395
+ // Reset to "All" option
396
+ const booleanInput = field('value')
397
+ if (booleanInput) booleanInput.value = ''
398
+ break
399
+
400
+ default:
401
+ // Generic clear for any other type
402
+ const input = field('value')
403
+ if (input) input.value = ''
404
+ }
405
+
406
+ this.syncFilterOps()
407
+
408
+ // Submit immediately (no debounce for clear)
409
+ this.submitFilters(null)
410
+ }
411
+
412
+ // Hide the value input for operators that take none (is_empty / is_not_empty)
413
+ syncFilterOps() {
414
+ this.element.querySelectorAll("select[data-filter-op]").forEach(select => {
415
+ const container = select.closest("[data-filter-container]")
416
+ const wrapper = container?.querySelector("[data-filter-value-wrapper]")
417
+ if (!container || !wrapper) return
418
+
419
+ const noValue = select.value === "is_empty" || select.value === "is_not_empty"
420
+ if (noValue) wrapper.querySelector("input").value = ""
421
+ wrapper.style.display = noValue ? "none" : "block"
422
+ container.className = noValue ? "grid grid-cols-1 gap-2" : "grid grid-cols-[1fr_2fr] gap-2"
423
+ })
424
+ }
425
+
426
+ // Tab switching
427
+ switchTab(event) {
428
+ event.preventDefault()
429
+ const tab = event.currentTarget.dataset.tab
430
+ this.currentTabValue = tab
431
+ }
432
+
433
+ switchTabTo(tab) {
434
+ // Update tab buttons
435
+ this.tabButtonTargets.forEach(button => {
436
+ const isActive = button.dataset.tab === tab
437
+ if (isActive) {
438
+ button.classList.add("text-indigo-600", "border-indigo-600")
439
+ button.classList.remove("text-gray-500", "border-transparent")
440
+ } else {
441
+ button.classList.remove("text-indigo-600", "border-indigo-600")
442
+ button.classList.add("text-gray-500", "border-transparent")
443
+ }
444
+ })
445
+
446
+ // Update tab content
447
+ this.tabContentTargets.forEach(content => {
448
+ if (content.dataset.tab === tab) {
449
+ content.classList.remove("hidden")
450
+ } else {
451
+ content.classList.add("hidden")
452
+ }
453
+ })
454
+
455
+ // Update panel title
456
+ if (this.hasPanelTitleTarget) {
457
+ if (tab === "columns") {
458
+ this.panelTitleTarget.textContent = this.panelTitleTarget.dataset.columnsTitle || "Customize Columns"
459
+ } else if (tab === "filters") {
460
+ this.panelTitleTarget.textContent = this.panelTitleTarget.dataset.filtersTitle || "Filters"
461
+ }
462
+ }
463
+
464
+ // Setup debounced filters when switching to filters tab
465
+ if (tab === "filters") {
466
+ setTimeout(() => {
467
+ this.setupDebouncedFilters()
468
+ }, 50)
469
+ }
470
+
471
+ // Sync column checkboxes and order with localStorage when switching to columns tab
472
+ if (tab === "columns") {
473
+ setTimeout(() => {
474
+ this.syncColumnCheckboxes()
475
+ // Sync column order in the drawer
476
+ this.syncColumnListOrder()
477
+ // Also update badge for all tables
478
+ this.updateAllColumnBadges()
479
+ }, 50)
480
+ }
481
+ }
482
+
483
+ // Column visibility management
484
+ updateColumnVisibility(event) {
485
+ event.preventDefault()
486
+ const checkbox = event.currentTarget
487
+ const form = checkbox.closest("form")
488
+ if (!form) return
489
+
490
+ const tableName = form.dataset.tableName
491
+ const columnName = checkbox.dataset.columnName
492
+ const isVisible = checkbox.checked
493
+
494
+ if (!tableName || !columnName) {
495
+ console.warn("Missing tableName or columnName", { tableName, columnName })
496
+ return
497
+ }
498
+
499
+ // Save to localStorage
500
+ this.saveColumnVisibility(tableName, columnName, isVisible)
501
+
502
+ // Apply visibility to table
503
+ this.applyColumnVisibility(tableName)
504
+
505
+ // Update badge
506
+ this.updateColumnBadge(tableName)
507
+
508
+ // Dispatch event to update export link
509
+ document.dispatchEvent(new CustomEvent("infotable:columns-changed", {
510
+ detail: { tableName }
511
+ }))
512
+ }
513
+
514
+ selectAllColumns(event) {
515
+ event.preventDefault()
516
+ const tableName = event.currentTarget.dataset.tableName
517
+ const form = document.getElementById(`column-visibility-form-${tableName}`)
518
+
519
+ if (form) {
520
+ const checkboxes = form.querySelectorAll('input[type="checkbox"]')
521
+ checkboxes.forEach(checkbox => {
522
+ checkbox.checked = true
523
+ const columnName = checkbox.dataset.columnName
524
+ this.saveColumnVisibility(tableName, columnName, true)
525
+ })
526
+ this.applyColumnVisibility(tableName)
527
+ this.updateColumnBadge(tableName)
528
+
529
+ // Dispatch event to update export link
530
+ document.dispatchEvent(new CustomEvent("infotable:columns-changed", {
531
+ detail: { tableName }
532
+ }))
533
+ }
534
+ }
535
+
536
+ deselectAllColumns(event) {
537
+ event.preventDefault()
538
+ const tableName = event.currentTarget.dataset.tableName
539
+ const form = document.getElementById(`column-visibility-form-${tableName}`)
540
+
541
+ if (form) {
542
+ const checkboxes = form.querySelectorAll('input[type="checkbox"]')
543
+ checkboxes.forEach(checkbox => {
544
+ checkbox.checked = false
545
+ const columnName = checkbox.dataset.columnName
546
+ this.saveColumnVisibility(tableName, columnName, false)
547
+ })
548
+ this.applyColumnVisibility(tableName)
549
+ this.updateColumnBadge(tableName)
550
+
551
+ // Dispatch event to update export link
552
+ document.dispatchEvent(new CustomEvent("infotable:columns-changed", {
553
+ detail: { tableName }
554
+ }))
555
+ }
556
+ }
557
+
558
+ // Save column visibility to localStorage
559
+ saveColumnVisibility(tableName, columnName, isVisible) {
560
+ const key = `infotable_columns_${tableName}`
561
+ let visibility = {}
562
+
563
+ try {
564
+ const stored = localStorage.getItem(key)
565
+ if (stored) {
566
+ visibility = JSON.parse(stored)
567
+ }
568
+ } catch (e) {
569
+ console.warn("Failed to parse column visibility from localStorage", e)
570
+ }
571
+
572
+ visibility[columnName] = isVisible
573
+ localStorage.setItem(key, JSON.stringify(visibility))
574
+ }
575
+
576
+ // Sync checkboxes with localStorage values or defaults
577
+ syncColumnCheckboxes() {
578
+ // Find all column visibility forms
579
+ const forms = this.contentTarget.querySelectorAll('[id^="column-visibility-form-"]')
580
+ forms.forEach(form => {
581
+ const tableName = form.dataset.tableName
582
+ const visibility = this.getColumnVisibility(tableName)
583
+
584
+ // Update checkboxes based on saved preferences or defaults
585
+ const checkboxes = form.querySelectorAll('input[type="checkbox"]')
586
+ checkboxes.forEach(checkbox => {
587
+ const columnName = checkbox.dataset.columnName
588
+ if (visibility && visibility.hasOwnProperty(columnName)) {
589
+ // Use saved preference from localStorage
590
+ checkbox.checked = visibility[columnName]
591
+ } else {
592
+ // Use default visibility from data attribute
593
+ const defaultVisible = checkbox.dataset.defaultVisible === "true"
594
+ checkbox.checked = defaultVisible
595
+ }
596
+ })
597
+
598
+ // Update badge after syncing
599
+ this.updateColumnBadge(tableName)
600
+ })
601
+ }
602
+
603
+ // Sync column list order in the drawer with localStorage
604
+ syncColumnListOrder() {
605
+ if (!this.hasColumnListTarget) return
606
+
607
+ this.columnListTargets.forEach(list => {
608
+ const tableName = list.dataset.tableName
609
+ const defaultSchema = JSON.parse(list.dataset.columnsSchema || '[]')
610
+ const storedOrder = this.loadColumnOrder(tableName)
611
+
612
+ if (!storedOrder) {
613
+ // No stored order, but still sync pin buttons with saved pinned state
614
+ const pinnedData = this.getAllColumnsPinned(tableName)
615
+ const columnItems = list.querySelectorAll('.column-item')
616
+ columnItems.forEach(item => {
617
+ const columnName = item.dataset.columnName
618
+ const pinned = pinnedData[columnName] || null
619
+ this.updatePinButtons(item, pinned)
620
+ })
621
+ return
622
+ }
623
+
624
+ // Validate stored order
625
+ if (!this.isSchemaValid(storedOrder, defaultSchema)) {
626
+ console.warn(`Schema mismatch for table ${tableName}, clearing stored order`)
627
+ this.clearColumnOrder(tableName)
628
+ return
629
+ }
630
+
631
+ // Apply order to the list
632
+ this.applyColumnOrderToList(tableName, storedOrder)
633
+
634
+ // Sync pin button styling with localStorage
635
+ const pinnedData = this.getAllColumnsPinned(tableName)
636
+ const columnItems = list.querySelectorAll('.column-item')
637
+ columnItems.forEach(item => {
638
+ const columnName = item.dataset.columnName
639
+ const pinned = pinnedData[columnName] || null
640
+ this.updatePinButtons(item, pinned)
641
+ })
642
+ })
643
+ }
644
+
645
+ // Get column visibility from localStorage
646
+ getColumnVisibility(tableName) {
647
+ const key = `infotable_columns_${tableName}`
648
+ try {
649
+ const stored = localStorage.getItem(key)
650
+ return stored ? JSON.parse(stored) : null
651
+ } catch (e) {
652
+ console.warn("Failed to parse column visibility from localStorage", e)
653
+ return null
654
+ }
655
+ }
656
+
657
+ // Apply column visibility to the table
658
+ applyColumnVisibility(tableName) {
659
+ const tableWrapper = document.querySelector(`[data-infotable--table-name-value="${tableName}"]`)
660
+ if (!tableWrapper) {
661
+ console.warn(`Table wrapper not found for: ${tableName}`)
662
+ return
663
+ }
664
+
665
+ // Try to find table in the wrapper or in turbo-frame
666
+ let table = tableWrapper.querySelector("table")
667
+ if (!table) {
668
+ // Table might be inside turbo-frame
669
+ const turboFrame = document.getElementById(`${tableName}_content`)
670
+ if (turboFrame) {
671
+ table = turboFrame.querySelector("table")
672
+ }
673
+ }
674
+
675
+ if (!table) {
676
+ console.warn(`Table not found for: ${tableName}`)
677
+ return
678
+ }
679
+
680
+ // Find all table headers and cells
681
+ const headers = table.querySelectorAll("thead th[data-column-name]")
682
+ const rows = table.querySelectorAll("tbody tr")
683
+
684
+ if (headers.length === 0) {
685
+ console.warn(`No column headers found for table: ${tableName}`)
686
+ return
687
+ }
688
+
689
+ // Get saved preferences from localStorage
690
+ const visibility = this.getColumnVisibility(tableName)
691
+
692
+ headers.forEach((header) => {
693
+ const columnName = header.dataset.columnName
694
+ const defaultVisible = header.dataset.defaultVisible === "true"
695
+
696
+ // Determine if column should be visible
697
+ let shouldShow
698
+ if (visibility && visibility.hasOwnProperty(columnName)) {
699
+ // Use saved preference
700
+ shouldShow = visibility[columnName]
701
+ } else {
702
+ // Use default visibility
703
+ shouldShow = defaultVisible
704
+ }
705
+
706
+ // Apply visibility
707
+ header.style.display = shouldShow ? "" : "none"
708
+
709
+ // Hide/show corresponding cells in all rows
710
+ rows.forEach(row => {
711
+ const cells = row.querySelectorAll("td[data-column-name]")
712
+ const cell = Array.from(cells).find(c => c.dataset.columnName === columnName)
713
+ if (cell) {
714
+ cell.style.display = shouldShow ? "" : "none"
715
+ }
716
+ })
717
+ })
718
+ }
719
+
720
+ // Update column badge count for a specific table
721
+ updateColumnBadge(tableName) {
722
+ // Find the badge for this table using the data attribute
723
+ const badge = Array.from(this.columnBadgeTargets || []).find(b =>
724
+ b.dataset.badgeTableName === tableName
725
+ )
726
+
727
+ if (!badge) {
728
+ // Fallback: find badge in tab button
729
+ const tabButton = Array.from(this.tabButtonTargets).find(button =>
730
+ button.dataset.tab === "columns" && button.dataset.tableName === tableName
731
+ )
732
+ if (tabButton) {
733
+ const fallbackBadge = tabButton.querySelector('[data-badge-table-name="' + tableName + '"]')
734
+ if (fallbackBadge) {
735
+ this.updateBadgeText(fallbackBadge, tableName)
736
+ }
737
+ }
738
+ return
739
+ }
740
+
741
+ this.updateBadgeText(badge, tableName)
742
+ }
743
+
744
+ // Helper method to update badge text
745
+ updateBadgeText(badge, tableName) {
746
+ // Find the form for this table
747
+ const form = document.getElementById(`column-visibility-form-${tableName}`)
748
+ if (!form) return
749
+
750
+ // Count visible and total columns
751
+ const checkboxes = form.querySelectorAll('input[type="checkbox"]')
752
+ const totalColumns = checkboxes.length
753
+ const visibleColumns = Array.from(checkboxes).filter(cb => cb.checked).length
754
+
755
+ // Update badge text
756
+ badge.textContent = `${visibleColumns} / ${totalColumns}`
757
+ }
758
+
759
+ // Update all column badges (useful when switching tabs)
760
+ updateAllColumnBadges() {
761
+ // Find all column visibility forms
762
+ const forms = this.contentTarget?.querySelectorAll('[id^="column-visibility-form-"]')
763
+ if (!forms) return
764
+
765
+ forms.forEach(form => {
766
+ const tableName = form.dataset.tableName
767
+ if (tableName) {
768
+ this.updateColumnBadge(tableName)
769
+ }
770
+ })
771
+ }
772
+
773
+ // ========== Column Pinning ==========
774
+
775
+ // Handle pin button click
776
+ pinColumn(event) {
777
+ event.preventDefault()
778
+ event.stopPropagation()
779
+
780
+ const button = event.currentTarget
781
+ const columnName = button.dataset.columnName
782
+ const pinDirection = button.dataset.pinDirection
783
+ const columnItem = button.closest('.column-item')
784
+ const form = button.closest('form')
785
+
786
+ if (!form || !columnName) return
787
+
788
+ const tableName = form.dataset.tableName
789
+
790
+ // Get current pinned state
791
+ const currentPinned = this.getColumnPinned(tableName, columnName)
792
+
793
+ // Toggle: if clicking same direction, unpin; otherwise, pin in that direction
794
+ const newPinned = (currentPinned === pinDirection) ? null : pinDirection
795
+
796
+ // Save to localStorage
797
+ this.saveColumnPinned(tableName, columnName, newPinned)
798
+
799
+ // Update button styling
800
+ this.updatePinButtons(columnItem, newPinned)
801
+
802
+ // Reorder columns in drawer based on pinned state
803
+ this.reorderColumnsWithPinning(tableName)
804
+
805
+ // Get the new order after pinning
806
+ const list = Array.from(this.columnListTargets).find(l => l.dataset.tableName === tableName)
807
+ if (list) {
808
+ const columnItems = list.querySelectorAll('.column-item')
809
+ const newOrder = Array.from(columnItems).map(item => item.dataset.columnName)
810
+
811
+ // Apply the new order to the table (reorders DOM)
812
+ this.applyColumnOrder(tableName, newOrder)
813
+ }
814
+
815
+ // Apply sticky positioning to table immediately
816
+ this.applyColumnPinningToTable(tableName)
817
+
818
+ // Dispatch event for export link update
819
+ document.dispatchEvent(new CustomEvent("infotable:columns-changed", {
820
+ detail: { tableName }
821
+ }))
822
+ }
823
+
824
+ // Save pinned state to localStorage
825
+ saveColumnPinned(tableName, columnName, pinned) {
826
+ const key = `infotable_column_pinned_${tableName}`
827
+ let pinnedData = {}
828
+
829
+ try {
830
+ const stored = localStorage.getItem(key)
831
+ if (stored) {
832
+ pinnedData = JSON.parse(stored)
833
+ }
834
+ } catch (e) {
835
+ console.warn("Failed to parse column pinned from localStorage", e)
836
+ }
837
+
838
+ if (pinned) {
839
+ pinnedData[columnName] = pinned
840
+ } else {
841
+ delete pinnedData[columnName]
842
+ }
843
+
844
+ localStorage.setItem(key, JSON.stringify(pinnedData))
845
+ }
846
+
847
+ // Get pinned state from localStorage
848
+ getColumnPinned(tableName, columnName) {
849
+ const key = `infotable_column_pinned_${tableName}`
850
+ try {
851
+ const stored = localStorage.getItem(key)
852
+ if (stored) {
853
+ const pinnedData = JSON.parse(stored)
854
+ return pinnedData[columnName] || null
855
+ }
856
+ } catch (e) {
857
+ console.warn("Failed to parse column pinned from localStorage", e)
858
+ }
859
+ return null
860
+ }
861
+
862
+ // Get all pinned columns
863
+ getAllColumnsPinned(tableName) {
864
+ const key = `infotable_column_pinned_${tableName}`
865
+ try {
866
+ const stored = localStorage.getItem(key)
867
+ return stored ? JSON.parse(stored) : {}
868
+ } catch (e) {
869
+ console.warn("Failed to parse column pinned from localStorage", e)
870
+ return {}
871
+ }
872
+ }
873
+
874
+ // Update pin button styling
875
+ updatePinButtons(columnItem, pinned) {
876
+ const leftBtn = columnItem.querySelector('.pin-left')
877
+ const rightBtn = columnItem.querySelector('.pin-right')
878
+
879
+ if (leftBtn) {
880
+ if (pinned === 'left') {
881
+ leftBtn.classList.add('bg-indigo-100', 'text-indigo-600')
882
+ leftBtn.classList.remove('text-gray-400', 'hover:bg-gray-100', 'hover:text-gray-600')
883
+ } else {
884
+ leftBtn.classList.remove('bg-indigo-100', 'text-indigo-600')
885
+ leftBtn.classList.add('text-gray-400', 'hover:bg-gray-100', 'hover:text-gray-600')
886
+ }
887
+ }
888
+
889
+ if (rightBtn) {
890
+ if (pinned === 'right') {
891
+ rightBtn.classList.add('bg-indigo-100', 'text-indigo-600')
892
+ rightBtn.classList.remove('text-gray-400', 'hover:bg-gray-100', 'hover:text-gray-600')
893
+ } else {
894
+ rightBtn.classList.remove('bg-indigo-100', 'text-indigo-600')
895
+ rightBtn.classList.add('text-gray-400', 'hover:bg-gray-100', 'hover:text-gray-600')
896
+ }
897
+ }
898
+ }
899
+
900
+ // Reorder columns in drawer based on pinning (left, unpinned, right)
901
+ reorderColumnsWithPinning(tableName) {
902
+ const list = Array.from(this.columnListTargets).find(l => l.dataset.tableName === tableName)
903
+ if (!list) return
904
+
905
+ const pinnedData = this.getAllColumnsPinned(tableName)
906
+ const columnItems = Array.from(list.querySelectorAll('.column-item'))
907
+
908
+ // Separate columns by pinned state
909
+ const leftPinned = []
910
+ const unpinned = []
911
+ const rightPinned = []
912
+
913
+ columnItems.forEach(item => {
914
+ const columnName = item.dataset.columnName
915
+ const pinned = pinnedData[columnName]
916
+
917
+ if (pinned === 'left') {
918
+ leftPinned.push(item)
919
+ } else if (pinned === 'right') {
920
+ rightPinned.push(item)
921
+ } else {
922
+ unpinned.push(item)
923
+ }
924
+ })
925
+
926
+ // Reorder in DOM: left, unpinned, right
927
+ leftPinned.forEach(item => list.appendChild(item))
928
+ unpinned.forEach(item => list.appendChild(item))
929
+ rightPinned.forEach(item => list.appendChild(item))
930
+
931
+ // Save new order to localStorage
932
+ const newOrder = [...leftPinned, ...unpinned, ...rightPinned].map(item => item.dataset.columnName)
933
+ this.saveColumnOrder(tableName, newOrder)
934
+ }
935
+
936
+ // Apply pinning to table (add sticky positioning)
937
+ applyColumnPinningToTable(tableName) {
938
+ const tableWrapper = document.querySelector(`[data-infotable--table-name-value="${tableName}"]`)
939
+ if (!tableWrapper) return
940
+
941
+ let table = tableWrapper.querySelector("table")
942
+ if (!table) {
943
+ const turboFrame = document.getElementById(`${tableName}_content`)
944
+ if (turboFrame) {
945
+ table = turboFrame.querySelector("table")
946
+ }
947
+ }
948
+
949
+ if (!table) return
950
+
951
+ const pinnedData = this.getAllColumnsPinned(tableName)
952
+ const headers = Array.from(table.querySelectorAll("thead th[data-column-name]"))
953
+ const rows = table.querySelectorAll("tbody tr")
954
+
955
+ // Calculate positions for pinned columns
956
+ let leftOffset = 0
957
+ let rightOffset = 0
958
+
959
+ // First pass: reset all columns
960
+ headers.forEach(header => {
961
+ header.style.position = ''
962
+ header.style.left = ''
963
+ header.style.right = ''
964
+ header.style.zIndex = ''
965
+ header.style.removeProperty('background-color')
966
+ header.style.removeProperty('background-image')
967
+ header.style.removeProperty('opacity')
968
+ header.classList.remove('infotable-pinned-left', 'infotable-pinned-right', '!bg-gray-50', "after:content-['']", "after:absolute", "after:top-0", "after:right-0", "after:bottom-0", "after:w-5", "after:pointer-events-none", "after:bg-gradient-to-r", "after:from-transparent", "after:to-black/[0.04]", "after:z-[1]", "before:content-['']", "before:absolute", "before:top-0", "before:left-0", "before:bottom-0", "before:w-5", "before:pointer-events-none", "before:bg-gradient-to-l", "before:from-transparent", "before:to-black/[0.04]", "before:z-[1]")
969
+ })
970
+
971
+ rows.forEach(row => {
972
+ const cells = row.querySelectorAll("td[data-column-name]")
973
+ cells.forEach(cell => {
974
+ cell.style.position = ''
975
+ cell.style.left = ''
976
+ cell.style.right = ''
977
+ cell.style.zIndex = ''
978
+ cell.style.removeProperty('background-color')
979
+ cell.style.removeProperty('background-image')
980
+ cell.style.removeProperty('opacity')
981
+ cell.classList.remove('infotable-pinned-left', 'infotable-pinned-right', '!bg-white', "after:content-['']", "after:absolute", "after:top-0", "after:right-0", "after:bottom-0", "after:w-5", "after:pointer-events-none", "after:bg-gradient-to-r", "after:from-transparent", "after:to-black/[0.04]", "after:z-[1]", "before:content-['']", "before:absolute", "before:top-0", "before:left-0", "before:bottom-0", "before:w-5", "before:pointer-events-none", "before:bg-gradient-to-l", "before:from-transparent", "before:to-black/[0.04]", "before:z-[1]")
982
+ })
983
+ })
984
+
985
+ // Find last left-pinned and first right-pinned columns
986
+ let lastLeftPinned = null
987
+ let firstRightPinned = null
988
+
989
+ headers.forEach(header => {
990
+ const columnName = header.dataset.columnName
991
+ const pinned = pinnedData[columnName]
992
+ if (pinned === 'left') {
993
+ lastLeftPinned = columnName
994
+ }
995
+ if (pinned === 'right' && !firstRightPinned) {
996
+ firstRightPinned = columnName
997
+ }
998
+ })
999
+
1000
+ // Apply sticky positioning for pinned columns
1001
+ headers.forEach(header => {
1002
+ const columnName = header.dataset.columnName
1003
+ const pinned = pinnedData[columnName]
1004
+
1005
+ if (pinned === 'left') {
1006
+ // Get existing inline styles
1007
+ const existingStyles = header.getAttribute('style') || ''
1008
+
1009
+ // Build comprehensive style string with all !important flags
1010
+ const newStyles = `
1011
+ position: sticky !important;
1012
+ left: ${leftOffset}px !important;
1013
+ z-index: 30 !important;
1014
+ background: #f9fafb !important;
1015
+ background-color: #f9fafb !important;
1016
+ background-image: none !important;
1017
+ background-position: initial !important;
1018
+ background-size: initial !important;
1019
+ background-repeat: initial !important;
1020
+ background-attachment: initial !important;
1021
+ opacity: 1 !important;
1022
+ ${existingStyles}
1023
+ `.replace(/\s+/g, ' ').trim()
1024
+
1025
+ // Set all styles at once via setAttribute
1026
+ header.setAttribute('style', newStyles)
1027
+ header.classList.add('infotable-pinned-left', '!bg-gray-50')
1028
+
1029
+ // Mark last left-pinned column
1030
+ if (columnName === lastLeftPinned) {
1031
+ 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]")
1032
+ }
1033
+
1034
+ // Apply to cells in all rows
1035
+ rows.forEach(row => {
1036
+ const cell = row.querySelector(`td[data-column-name="${columnName}"]`)
1037
+ if (cell) {
1038
+ // Get existing inline styles
1039
+ const existingStyles = cell.getAttribute('style') || ''
1040
+
1041
+ // Build comprehensive style string
1042
+ const newStyles = `
1043
+ position: sticky !important;
1044
+ left: ${leftOffset}px !important;
1045
+ z-index: 15 !important;
1046
+ background: #ffffff !important;
1047
+ background-color: #ffffff !important;
1048
+ background-image: none !important;
1049
+ background-position: initial !important;
1050
+ background-size: initial !important;
1051
+ background-repeat: initial !important;
1052
+ background-attachment: initial !important;
1053
+ opacity: 1 !important;
1054
+ ${existingStyles}
1055
+ `.replace(/\s+/g, ' ').trim()
1056
+
1057
+ cell.setAttribute('style', newStyles)
1058
+ cell.classList.add('infotable-pinned-left', '!bg-white')
1059
+
1060
+ if (columnName === lastLeftPinned) {
1061
+ 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]")
1062
+ }
1063
+ }
1064
+ })
1065
+
1066
+ leftOffset += header.offsetWidth
1067
+ } else if (pinned === 'right') {
1068
+ // Get existing inline styles
1069
+ const existingStyles = header.getAttribute('style') || ''
1070
+
1071
+ // Build comprehensive style string
1072
+ const newStyles = `
1073
+ position: sticky !important;
1074
+ right: ${rightOffset}px !important;
1075
+ z-index: 30 !important;
1076
+ background: #f9fafb !important;
1077
+ background-color: #f9fafb !important;
1078
+ background-image: none !important;
1079
+ background-position: initial !important;
1080
+ background-size: initial !important;
1081
+ background-repeat: initial !important;
1082
+ background-attachment: initial !important;
1083
+ opacity: 1 !important;
1084
+ ${existingStyles}
1085
+ `.replace(/\s+/g, ' ').trim()
1086
+
1087
+ header.setAttribute('style', newStyles)
1088
+ header.classList.add('infotable-pinned-right', '!bg-gray-50')
1089
+
1090
+ // Mark first right-pinned column
1091
+ if (columnName === firstRightPinned) {
1092
+ 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]")
1093
+ }
1094
+
1095
+ // Apply to cells in all rows
1096
+ rows.forEach(row => {
1097
+ const cell = row.querySelector(`td[data-column-name="${columnName}"]`)
1098
+ if (cell) {
1099
+ // Get existing inline styles
1100
+ const existingStyles = cell.getAttribute('style') || ''
1101
+
1102
+ // Build comprehensive style string
1103
+ const newStyles = `
1104
+ position: sticky !important;
1105
+ right: ${rightOffset}px !important;
1106
+ z-index: 15 !important;
1107
+ background: #ffffff !important;
1108
+ background-color: #ffffff !important;
1109
+ background-image: none !important;
1110
+ background-position: initial !important;
1111
+ background-size: initial !important;
1112
+ background-repeat: initial !important;
1113
+ background-attachment: initial !important;
1114
+ opacity: 1 !important;
1115
+ ${existingStyles}
1116
+ `.replace(/\s+/g, ' ').trim()
1117
+
1118
+ cell.setAttribute('style', newStyles)
1119
+ cell.classList.add('infotable-pinned-right', '!bg-white')
1120
+
1121
+ if (columnName === firstRightPinned) {
1122
+ 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]")
1123
+ }
1124
+ }
1125
+ })
1126
+
1127
+ rightOffset += header.offsetWidth
1128
+ }
1129
+ })
1130
+ }
1131
+
1132
+ // ========== Column Ordering with Drag and Drop ==========
1133
+
1134
+ // Initialize Sortable for drag and drop column ordering
1135
+ initializeSortable() {
1136
+ if (!this.hasColumnListTarget) return
1137
+
1138
+ this.columnListTargets.forEach(list => {
1139
+ const tableName = list.dataset.tableName
1140
+ const defaultSchema = JSON.parse(list.dataset.columnsSchema || '[]')
1141
+
1142
+ // Validate stored schema and load column order
1143
+ this.validateAndLoadColumnOrder(tableName, defaultSchema)
1144
+
1145
+ // Initialize Sortable.js
1146
+ const sortable = new Sortable(list, {
1147
+ animation: 150,
1148
+ handle: '.drag-handle',
1149
+ ghostClass: 'opacity-50',
1150
+ dragClass: 'shadow-lg',
1151
+ onEnd: (evt) => {
1152
+ this.handleColumnReorder(tableName, list)
1153
+ }
1154
+ })
1155
+
1156
+ // Store sortable instance for cleanup
1157
+ if (!this.sortableInstances) {
1158
+ this.sortableInstances = new Map()
1159
+ }
1160
+ this.sortableInstances.set(tableName, sortable)
1161
+ })
1162
+ }
1163
+
1164
+ // Handle column reorder after drag and drop
1165
+ handleColumnReorder(tableName, list) {
1166
+ // Get new order from DOM
1167
+ const columnItems = list.querySelectorAll('.column-item')
1168
+ const newOrder = Array.from(columnItems).map(item => item.dataset.columnName)
1169
+
1170
+ // Unpin all columns when manually reordering
1171
+ const pinnedKey = `infotable_column_pinned_${tableName}`
1172
+ localStorage.removeItem(pinnedKey)
1173
+
1174
+ // Update pin button styling for all items
1175
+ columnItems.forEach(item => {
1176
+ this.updatePinButtons(item, null)
1177
+ })
1178
+
1179
+ // Save to localStorage
1180
+ this.saveColumnOrder(tableName, newOrder)
1181
+
1182
+ // Apply order to table
1183
+ this.applyColumnOrder(tableName, newOrder)
1184
+
1185
+ // Remove sticky positioning since all are unpinned
1186
+ this.applyColumnPinningToTable(tableName)
1187
+
1188
+ // Dispatch event for export link update
1189
+ document.dispatchEvent(new CustomEvent("infotable:columns-changed", {
1190
+ detail: { tableName }
1191
+ }))
1192
+ }
1193
+
1194
+ // Save column order to localStorage
1195
+ saveColumnOrder(tableName, order) {
1196
+ const key = `infotable_column_order_${tableName}`
1197
+ localStorage.setItem(key, JSON.stringify(order))
1198
+ }
1199
+
1200
+ // Load column order from localStorage
1201
+ loadColumnOrder(tableName) {
1202
+ const key = `infotable_column_order_${tableName}`
1203
+ try {
1204
+ const stored = localStorage.getItem(key)
1205
+ return stored ? JSON.parse(stored) : null
1206
+ } catch (e) {
1207
+ console.warn("Failed to parse column order from localStorage", e)
1208
+ return null
1209
+ }
1210
+ }
1211
+
1212
+ // Validate stored schema against backend schema
1213
+ validateAndLoadColumnOrder(tableName, defaultSchema) {
1214
+ const storedOrder = this.loadColumnOrder(tableName)
1215
+
1216
+ if (!storedOrder) {
1217
+ // No stored order, nothing to validate
1218
+ return
1219
+ }
1220
+
1221
+ // Check if stored order matches backend schema
1222
+ const isValid = this.isSchemaValid(storedOrder, defaultSchema)
1223
+
1224
+ if (!isValid) {
1225
+ // Schema mismatch - clear invalid localStorage
1226
+ console.warn(`Schema mismatch for table ${tableName}, clearing stored order`)
1227
+ this.clearColumnOrder(tableName)
1228
+ return
1229
+ }
1230
+
1231
+ // Valid schema, apply the order
1232
+ this.applyColumnOrderToList(tableName, storedOrder)
1233
+ this.applyColumnOrder(tableName, storedOrder)
1234
+ }
1235
+
1236
+ // Check if stored order is valid against default schema
1237
+ isSchemaValid(storedOrder, defaultSchema) {
1238
+ // Extract column names from schema (handle both old and new formats)
1239
+ const schemaNames = defaultSchema.map(item =>
1240
+ typeof item === 'string' ? item : item.name
1241
+ )
1242
+
1243
+ // Must have same length
1244
+ if (storedOrder.length !== schemaNames.length) {
1245
+ return false
1246
+ }
1247
+
1248
+ // Must contain same columns (order can differ)
1249
+ const storedSet = new Set(storedOrder)
1250
+ const defaultSet = new Set(schemaNames)
1251
+
1252
+ if (storedSet.size !== defaultSet.size) {
1253
+ return false
1254
+ }
1255
+
1256
+ for (const col of storedOrder) {
1257
+ if (!defaultSet.has(col)) {
1258
+ return false
1259
+ }
1260
+ }
1261
+
1262
+ return true
1263
+ }
1264
+
1265
+ // Apply column order to the column list in the drawer
1266
+ applyColumnOrderToList(tableName, order) {
1267
+ const list = Array.from(this.columnListTargets).find(l => l.dataset.tableName === tableName)
1268
+ if (!list) return
1269
+
1270
+ const columnItems = Array.from(list.querySelectorAll('.column-item'))
1271
+ const itemMap = new Map()
1272
+
1273
+ // Create map of column name to DOM element
1274
+ columnItems.forEach(item => {
1275
+ itemMap.set(item.dataset.columnName, item)
1276
+ })
1277
+
1278
+ // Reorder DOM elements according to order array
1279
+ order.forEach(columnName => {
1280
+ const item = itemMap.get(columnName)
1281
+ if (item) {
1282
+ list.appendChild(item)
1283
+ }
1284
+ })
1285
+ }
1286
+
1287
+ // Apply column order to the table
1288
+ applyColumnOrder(tableName, order) {
1289
+ const tableWrapper = document.querySelector(`[data-infotable--table-name-value="${tableName}"]`)
1290
+ if (!tableWrapper) return
1291
+
1292
+ // Try to find table in the wrapper or in turbo-frame
1293
+ let table = tableWrapper.querySelector("table")
1294
+ if (!table) {
1295
+ const turboFrame = document.getElementById(`${tableName}_content`)
1296
+ if (turboFrame) {
1297
+ table = turboFrame.querySelector("table")
1298
+ }
1299
+ }
1300
+
1301
+ if (!table) return
1302
+
1303
+ // Get headers and create a map
1304
+ const headerRow = table.querySelector("thead tr")
1305
+ if (!headerRow) return
1306
+
1307
+ const headers = Array.from(headerRow.querySelectorAll("th[data-column-name]"))
1308
+ const headerMap = new Map()
1309
+
1310
+ headers.forEach((header, index) => {
1311
+ headerMap.set(header.dataset.columnName, { element: header, originalIndex: index })
1312
+ })
1313
+
1314
+ // Reorder headers according to order array
1315
+ order.forEach(columnName => {
1316
+ const headerInfo = headerMap.get(columnName)
1317
+ if (headerInfo) {
1318
+ headerRow.appendChild(headerInfo.element)
1319
+ }
1320
+ })
1321
+
1322
+ // Reorder cells in each row
1323
+ const rows = table.querySelectorAll("tbody tr")
1324
+ rows.forEach(row => {
1325
+ const cells = Array.from(row.querySelectorAll("td[data-column-name]"))
1326
+ const cellMap = new Map()
1327
+
1328
+ cells.forEach(cell => {
1329
+ cellMap.set(cell.dataset.columnName, cell)
1330
+ })
1331
+
1332
+ order.forEach(columnName => {
1333
+ const cell = cellMap.get(columnName)
1334
+ if (cell) {
1335
+ row.appendChild(cell)
1336
+ }
1337
+ })
1338
+ })
1339
+ }
1340
+
1341
+ // Clear column order from localStorage
1342
+ clearColumnOrder(tableName) {
1343
+ const key = `infotable_column_order_${tableName}`
1344
+ localStorage.removeItem(key)
1345
+ }
1346
+
1347
+ // Reset to defaults - clear all custom settings
1348
+ resetToDefaults(event) {
1349
+ event.preventDefault()
1350
+ const tableName = event.currentTarget.dataset.tableName
1351
+
1352
+ if (!tableName) return
1353
+
1354
+ // Clear column visibility
1355
+ const visibilityKey = `infotable_columns_${tableName}`
1356
+ localStorage.removeItem(visibilityKey)
1357
+
1358
+ // Clear column order
1359
+ this.clearColumnOrder(tableName)
1360
+
1361
+ // Clear column pinning
1362
+ const pinnedKey = `infotable_column_pinned_${tableName}`
1363
+ localStorage.removeItem(pinnedKey)
1364
+
1365
+ // Reset checkboxes to defaults
1366
+ const form = document.getElementById(`column-visibility-form-${tableName}`)
1367
+ if (form) {
1368
+ const checkboxes = form.querySelectorAll('input[type="checkbox"]')
1369
+ checkboxes.forEach(checkbox => {
1370
+ const defaultVisible = checkbox.dataset.defaultVisible === "true"
1371
+ checkbox.checked = defaultVisible
1372
+ })
1373
+
1374
+ // Reset pin buttons to defaults
1375
+ const columnItems = form.querySelectorAll('.column-item')
1376
+ columnItems.forEach(item => {
1377
+ const defaultPinned = item.dataset.defaultPinned
1378
+ this.updatePinButtons(item, defaultPinned === 'left' ? 'left' : defaultPinned === 'right' ? 'right' : null)
1379
+ })
1380
+
1381
+ // Reset column order in the drawer
1382
+ const defaultSchema = JSON.parse(form.dataset.columnsSchema || '[]')
1383
+ const schemaNames = defaultSchema.map(item => typeof item === 'string' ? item : item.name)
1384
+ this.applyColumnOrderToList(tableName, schemaNames)
1385
+ }
1386
+
1387
+ // Apply defaults to table
1388
+ this.applyColumnVisibility(tableName)
1389
+
1390
+ // Apply default column order to table
1391
+ const list = Array.from(this.columnListTargets).find(l => l.dataset.tableName === tableName)
1392
+ if (list) {
1393
+ const defaultSchema = JSON.parse(list.dataset.columnsSchema || '[]')
1394
+ const schemaNames = defaultSchema.map(item => typeof item === 'string' ? item : item.name)
1395
+ this.applyColumnOrder(tableName, schemaNames)
1396
+ }
1397
+
1398
+ // Remove sticky positioning
1399
+ this.applyColumnPinningToTable(tableName)
1400
+
1401
+ // Update badge
1402
+ this.updateColumnBadge(tableName)
1403
+
1404
+ // Dispatch event for export link update
1405
+ document.dispatchEvent(new CustomEvent("infotable:columns-changed", {
1406
+ detail: { tableName }
1407
+ }))
1408
+
1409
+ // Show feedback (optional)
1410
+ }
1411
+
1412
+ }