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.
- checksums.yaml +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +1043 -0
- data/Rakefile +6 -0
- data/app/assets/javascripts/infotable/controllers/drawer_controller.js +1412 -0
- data/app/assets/javascripts/infotable/controllers/table_controller.js +921 -0
- data/app/assets/javascripts/infotable/index.js +14 -0
- data/app/controllers/infotable/application_controller.rb +4 -0
- data/app/controllers/infotable/tables_controller.rb +299 -0
- data/app/helpers/infotable/application_helper.rb +5 -0
- data/app/helpers/infotable/tables_helper.rb +354 -0
- data/app/jobs/infotable/application_job.rb +4 -0
- data/app/mailers/infotable/application_mailer.rb +6 -0
- data/app/models/infotable/application_record.rb +5 -0
- data/app/views/infotable/tables/_column_visibility.html.erb +100 -0
- data/app/views/infotable/tables/_filter_drawer.html.erb +65 -0
- data/app/views/infotable/tables/_next_page_frame.html.erb +39 -0
- data/app/views/infotable/tables/_pagination.html.erb +101 -0
- data/app/views/infotable/tables/_side_panel.html.erb +63 -0
- data/app/views/infotable/tables/_table.html.erb +135 -0
- data/app/views/infotable/tables/_table_rows.html.erb +38 -0
- data/app/views/infotable/tables/filters/_boolean.html.erb +22 -0
- data/app/views/infotable/tables/filters/_date.html.erb +30 -0
- data/app/views/infotable/tables/filters/_number.html.erb +32 -0
- data/app/views/infotable/tables/filters/_select.html.erb +68 -0
- data/app/views/infotable/tables/filters/_text.html.erb +38 -0
- data/app/views/infotable/tables/show.html.erb +113 -0
- data/app/views/layouts/infotable/application.html.erb +15 -0
- data/config/importmap.rb +6 -0
- data/config/locales/en.yml +88 -0
- data/config/locales/pl.yml +88 -0
- data/config/routes.rb +6 -0
- data/lib/generators/infotable/install/install_generator.rb +565 -0
- data/lib/generators/infotable/install/templates/TAILWIND_CONFIG.md +131 -0
- data/lib/generators/infotable/install/templates/initializer.rb +26 -0
- data/lib/generators/infotable/install/templates/tailwind.config.js +284 -0
- data/lib/generators/infotable/table/table_generator.rb +95 -0
- data/lib/generators/infotable/table/templates/table.rb.tt +26 -0
- data/lib/infotable/base.rb +463 -0
- data/lib/infotable/batch_actions.rb +34 -0
- data/lib/infotable/configuration.rb +53 -0
- data/lib/infotable/engine.rb +51 -0
- data/lib/infotable/filter_processor.rb +326 -0
- data/lib/infotable/formatters/action_formatter.rb +141 -0
- data/lib/infotable/formatters/avatar_formatter.rb +49 -0
- data/lib/infotable/formatters/badge_formatter.rb +80 -0
- data/lib/infotable/formatters/base_formatter.rb +47 -0
- data/lib/infotable/formatters/boolean_formatter.rb +38 -0
- data/lib/infotable/formatters/currency_formatter.rb +22 -0
- data/lib/infotable/formatters/date_formatter.rb +58 -0
- data/lib/infotable/formatters/link_formatter.rb +48 -0
- data/lib/infotable/formatters/percent_formatter.rb +27 -0
- data/lib/infotable/search_processor.rb +42 -0
- data/lib/infotable/table_resolver.rb +45 -0
- data/lib/infotable/version.rb +3 -0
- data/lib/infotable.rb +54 -0
- data/lib/tasks/infotable.rake +55 -0
- 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
|
+
}
|