rdbr 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 (83) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +36 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +155 -0
  5. data/app/public/application.js +609 -0
  6. data/app/public/database-diagram.js +602 -0
  7. data/app/public/styles.css +2141 -0
  8. data/app/views/data.haml +166 -0
  9. data/app/views/group.haml +50 -0
  10. data/app/views/index.haml +108 -0
  11. data/app/views/layout.haml +59 -0
  12. data/app/views/record.haml +81 -0
  13. data/demo/README.md +47 -0
  14. data/demo/compose.yml +34 -0
  15. data/demo/seed.rb +270 -0
  16. data/demo/smoke.rb +13 -0
  17. data/docs/RELEASING.md +30 -0
  18. data/docs/VALUE_PRESENTATION.md +54 -0
  19. data/exe/rdbr +5 -0
  20. data/lib/rdbr/access/read_only.rb +61 -0
  21. data/lib/rdbr/access.rb +6 -0
  22. data/lib/rdbr/browser_launcher.rb +71 -0
  23. data/lib/rdbr/catalog/connection.rb +68 -0
  24. data/lib/rdbr/catalog/errors.rb +12 -0
  25. data/lib/rdbr/catalog/mysql/queries.rb +131 -0
  26. data/lib/rdbr/catalog/mysql.rb +146 -0
  27. data/lib/rdbr/catalog/postgresql/queries.rb +171 -0
  28. data/lib/rdbr/catalog/postgresql.rb +138 -0
  29. data/lib/rdbr/catalog/reader.rb +30 -0
  30. data/lib/rdbr/catalog/serializer.rb +24 -0
  31. data/lib/rdbr/catalog/sqlite.rb +182 -0
  32. data/lib/rdbr/catalog.rb +20 -0
  33. data/lib/rdbr/cli.rb +74 -0
  34. data/lib/rdbr/cli_options.rb +63 -0
  35. data/lib/rdbr/metadata/column.rb +44 -0
  36. data/lib/rdbr/metadata/constraints.rb +25 -0
  37. data/lib/rdbr/metadata/database.rb +37 -0
  38. data/lib/rdbr/metadata/index.rb +30 -0
  39. data/lib/rdbr/metadata/keys.rb +56 -0
  40. data/lib/rdbr/metadata/namespace.rb +26 -0
  41. data/lib/rdbr/metadata/relation.rb +104 -0
  42. data/lib/rdbr/metadata/relationship_inference.rb +75 -0
  43. data/lib/rdbr/metadata/relationships.rb +126 -0
  44. data/lib/rdbr/metadata/values.rb +73 -0
  45. data/lib/rdbr/metadata.rb +10 -0
  46. data/lib/rdbr/presentation/record_identity.rb +125 -0
  47. data/lib/rdbr/query/connection.rb +138 -0
  48. data/lib/rdbr/query/errors.rb +33 -0
  49. data/lib/rdbr/query/lookup.rb +40 -0
  50. data/lib/rdbr/query/mysql.rb +15 -0
  51. data/lib/rdbr/query/page.rb +66 -0
  52. data/lib/rdbr/query/postgresql.rb +7 -0
  53. data/lib/rdbr/query/postgresql_dialect.rb +11 -0
  54. data/lib/rdbr/query/relational.rb +239 -0
  55. data/lib/rdbr/query/sqlite.rb +15 -0
  56. data/lib/rdbr/query/type_support.rb +68 -0
  57. data/lib/rdbr/query/value_limiter.rb +38 -0
  58. data/lib/rdbr/query.rb +32 -0
  59. data/lib/rdbr/version.rb +3 -0
  60. data/lib/rdbr/web/application.rb +234 -0
  61. data/lib/rdbr/web/array_value_helpers.rb +92 -0
  62. data/lib/rdbr/web/binary_value_helpers.rb +51 -0
  63. data/lib/rdbr/web/column_preferences.rb +56 -0
  64. data/lib/rdbr/web/database_graph.rb +91 -0
  65. data/lib/rdbr/web/deferred_column_value_helpers.rb +20 -0
  66. data/lib/rdbr/web/deferred_value_helpers.rb +76 -0
  67. data/lib/rdbr/web/document_value_helpers.rb +68 -0
  68. data/lib/rdbr/web/filter_presentation_helpers.rb +44 -0
  69. data/lib/rdbr/web/helpers.rb +42 -0
  70. data/lib/rdbr/web/identifier_value_helpers.rb +20 -0
  71. data/lib/rdbr/web/json_page_stream.rb +30 -0
  72. data/lib/rdbr/web/navigation_helpers.rb +115 -0
  73. data/lib/rdbr/web/presentation_helpers.rb +114 -0
  74. data/lib/rdbr/web/range_value_helpers.rb +86 -0
  75. data/lib/rdbr/web/record_value_helpers.rb +90 -0
  76. data/lib/rdbr/web/related_value_helpers.rb +99 -0
  77. data/lib/rdbr/web/relationship_loader.rb +65 -0
  78. data/lib/rdbr/web/search_value_helpers.rb +58 -0
  79. data/lib/rdbr/web/spatial_value_helpers.rb +83 -0
  80. data/lib/rdbr/web/through_relationship_loader.rb +90 -0
  81. data/lib/rdbr/web/vector_value_helpers.rb +59 -0
  82. data/lib/rdbr.rb +25 -0
  83. metadata +298 -0
@@ -0,0 +1,609 @@
1
+ (() => {
2
+ const picker = document.querySelector('[data-theme-picker]');
3
+ if (!picker) return;
4
+
5
+ const storedTheme = window.localStorage.getItem('rdbr-theme');
6
+ picker.value = ['dark', 'madhat2r'].includes(storedTheme) ? 'dark' : 'default';
7
+ picker.addEventListener('change', () => {
8
+ if (picker.value === 'dark') {
9
+ document.documentElement.dataset.theme = picker.value;
10
+ window.localStorage.setItem('rdbr-theme', picker.value);
11
+ } else {
12
+ delete document.documentElement.dataset.theme;
13
+ window.localStorage.removeItem('rdbr-theme');
14
+ }
15
+ });
16
+ })();
17
+
18
+ (() => {
19
+ const dialog = document.querySelector('#date-filter');
20
+ if (!dialog) return;
21
+
22
+ const fromInput = dialog.querySelector('[data-date-from]');
23
+ const toInput = dialog.querySelector('[data-date-to]');
24
+ const fromPicker = dialog.querySelector('[data-native-date-from]');
25
+ const toPicker = dialog.querySelector('[data-native-date-to]');
26
+ let column;
27
+
28
+ const dateText = (date) => {
29
+ const year = date.getFullYear();
30
+ const month = String(date.getMonth() + 1).padStart(2, '0');
31
+ const day = String(date.getDate()).padStart(2, '0');
32
+ return `${year}-${month}-${day}`;
33
+ };
34
+ const shifted = (date, days) => new Date(date.getFullYear(), date.getMonth(), date.getDate() + days);
35
+ const parseIsoDate = (input) => {
36
+ const match = input.value.match(/^(\d{4})-(\d{2})-(\d{2})$/);
37
+ const date = match && new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3]));
38
+ const valid = date &&
39
+ date.getFullYear() === Number(match[1]) &&
40
+ date.getMonth() === Number(match[2]) - 1 &&
41
+ date.getDate() === Number(match[3]);
42
+ input.setCustomValidity(valid ? '' : 'Enter a valid date in YYYY-MM-DD format.');
43
+ return valid ? date : undefined;
44
+ };
45
+
46
+ const open = (trigger) => {
47
+ column = trigger.dataset.dateFilterColumn;
48
+ dialog.querySelector('.date-filter-column').textContent = column;
49
+ const value = trigger.dataset.dateFilterValue;
50
+ if (value) {
51
+ const day = value.slice(0, 10);
52
+ fromInput.value = day;
53
+ toInput.value = day;
54
+ fromPicker.value = day;
55
+ toPicker.value = day;
56
+ }
57
+ dialog.hidden = false;
58
+ fromInput.focus();
59
+ };
60
+
61
+ const applyRange = (from, to) => {
62
+ const url = new URL(window.location.href);
63
+ Array.from(url.searchParams.keys())
64
+ .filter((key) => key.startsWith(`filter[${column}]`))
65
+ .forEach((key) => url.searchParams.delete(key));
66
+ url.searchParams.set(`filter[${column}][from]`, dateText(from));
67
+ url.searchParams.set(`filter[${column}][to]`, dateText(to));
68
+ url.searchParams.set('page', '1');
69
+ window.location.assign(url);
70
+ };
71
+
72
+ document.addEventListener('click', (event) => {
73
+ const trigger = event.target.closest('[data-date-filter-column]');
74
+ if (trigger) open(trigger);
75
+ });
76
+ [[fromInput, fromPicker], [toInput, toPicker]].forEach(([input, picker]) => {
77
+ input.addEventListener('input', () => {
78
+ picker.value = /^\d{4}-\d{2}-\d{2}$/.test(input.value) ? input.value : '';
79
+ });
80
+ picker.addEventListener('change', () => {
81
+ if (picker.value) input.value = picker.value;
82
+ input.setCustomValidity('');
83
+ });
84
+ });
85
+ dialog.addEventListener('click', (event) => {
86
+ if (event.target === dialog || event.target.closest('[data-close-date-filter]')) dialog.hidden = true;
87
+ const pickerName = event.target.closest('[data-open-date-picker]')?.dataset.openDatePicker;
88
+ if (pickerName) {
89
+ const picker = pickerName === 'from' ? fromPicker : toPicker;
90
+ if (picker.showPicker) picker.showPicker();
91
+ else picker.click();
92
+ return;
93
+ }
94
+ const preset = event.target.closest('[data-date-preset]')?.dataset.datePreset;
95
+ if (!preset) return;
96
+
97
+ const today = new Date();
98
+ if (preset === 'today') applyRange(today, today);
99
+ if (preset === 'yesterday') applyRange(shifted(today, -1), shifted(today, -1));
100
+ if (preset === 'last-7-days') applyRange(shifted(today, -6), today);
101
+ if (preset === 'last-30-days') applyRange(shifted(today, -29), today);
102
+ if (preset === 'this-month') {
103
+ applyRange(new Date(today.getFullYear(), today.getMonth(), 1),
104
+ new Date(today.getFullYear(), today.getMonth() + 1, 0));
105
+ }
106
+ });
107
+ dialog.querySelector('form').addEventListener('submit', (event) => {
108
+ event.preventDefault();
109
+ toInput.setCustomValidity('');
110
+ const from = parseIsoDate(fromInput);
111
+ const inclusiveTo = parseIsoDate(toInput);
112
+ if (!from || !inclusiveTo) {
113
+ dialog.querySelector('form').reportValidity();
114
+ return;
115
+ }
116
+ if (from > inclusiveTo) {
117
+ toInput.setCustomValidity('The To date must be on or after the From date.');
118
+ toInput.reportValidity();
119
+ return;
120
+ }
121
+ applyRange(from, inclusiveTo);
122
+ });
123
+ document.addEventListener('keydown', (event) => {
124
+ if (event.key === 'Escape') dialog.hidden = true;
125
+ });
126
+ })();
127
+
128
+ (() => {
129
+ document.querySelectorAll('[data-open-dialog]').forEach((trigger) => {
130
+ trigger.addEventListener('click', () => {
131
+ const dialog = document.getElementById(trigger.dataset.openDialog);
132
+ if (!dialog) return;
133
+
134
+ dialog.hidden = false;
135
+ dialog.querySelector('[data-close-dialog]')?.focus();
136
+ });
137
+ });
138
+
139
+ document.querySelectorAll('.text-dialog').forEach((dialog) => {
140
+ dialog.addEventListener('click', (event) => {
141
+ if (event.target === dialog || event.target.closest('[data-close-dialog]')) dialog.hidden = true;
142
+ });
143
+ });
144
+
145
+ document.addEventListener('keydown', (event) => {
146
+ if (event.key !== 'Escape') return;
147
+
148
+ document.querySelectorAll('.text-dialog:not([hidden])').forEach((dialog) => { dialog.hidden = true; });
149
+ });
150
+ })();
151
+
152
+ (() => {
153
+ const dialog = document.createElement('div');
154
+ dialog.className = 'deferred-value-popover popup-surface';
155
+ dialog.id = 'deferred-value-popover';
156
+ dialog.setAttribute('role', 'dialog');
157
+ dialog.setAttribute('aria-label', 'Identifier value');
158
+ dialog.hidden = true;
159
+ dialog.innerHTML = '<button class="dialog-close" type="button" aria-label="Close">Ă—</button><pre></pre>';
160
+ document.body.append(dialog);
161
+
162
+ let activeView;
163
+ const closeDialog = () => {
164
+ dialog.hidden = true;
165
+ activeView?.setAttribute('aria-expanded', 'false');
166
+ activeView = undefined;
167
+ };
168
+ const openDialog = (trigger) => {
169
+ activeView?.setAttribute('aria-expanded', 'false');
170
+ activeView = trigger;
171
+ activeView.setAttribute('aria-expanded', 'true');
172
+ dialog.querySelector('pre').textContent = trigger.dataset.viewValue;
173
+ dialog.hidden = false;
174
+
175
+ const triggerBounds = trigger.getBoundingClientRect();
176
+ const dialogBounds = dialog.getBoundingClientRect();
177
+ const margin = 8;
178
+ const maximumLeft = window.innerWidth - dialogBounds.width - margin;
179
+ const left = Math.max(margin, Math.min(triggerBounds.left, maximumLeft));
180
+ const below = triggerBounds.bottom + margin;
181
+ const top = below + dialogBounds.height <= window.innerHeight ?
182
+ below : Math.max(margin, triggerBounds.top - dialogBounds.height - margin);
183
+ dialog.style.left = `${left}px`;
184
+ dialog.style.top = `${top}px`;
185
+ };
186
+ const copyFallback = (value) => {
187
+ const input = document.createElement('textarea');
188
+ input.value = value;
189
+ input.style.position = 'fixed';
190
+ input.style.opacity = '0';
191
+ document.body.append(input);
192
+ input.select();
193
+ document.execCommand('copy');
194
+ input.remove();
195
+ };
196
+
197
+ document.addEventListener('click', async (event) => {
198
+ const view = event.target.closest('[data-view-value]');
199
+ if (view) {
200
+ if (view === activeView && !dialog.hidden) closeDialog();
201
+ else openDialog(view);
202
+ return;
203
+ }
204
+
205
+ const copy = event.target.closest('[data-copy-value], [data-copy-target]');
206
+ if (!copy) {
207
+ if (!dialog.hidden && !dialog.contains(event.target)) closeDialog();
208
+ return;
209
+ }
210
+
211
+ try {
212
+ const target = copy.dataset.copyTarget ? document.getElementById(copy.dataset.copyTarget) : undefined;
213
+ const value = target ? target.querySelector('pre').textContent : copy.dataset.copyValue;
214
+ if (navigator.clipboard?.writeText) await navigator.clipboard.writeText(value);
215
+ else copyFallback(value);
216
+ const status = copy.parentElement.querySelector('.deferred-copy-status');
217
+ status.textContent = 'Copied';
218
+ window.setTimeout(() => { status.textContent = ''; }, 1500);
219
+ } catch (_error) {
220
+ copy.parentElement.querySelector('.deferred-copy-status').textContent = 'Copy failed';
221
+ }
222
+ });
223
+ dialog.addEventListener('click', (event) => {
224
+ if (event.target.closest('.dialog-close')) closeDialog();
225
+ });
226
+ document.addEventListener('keydown', (event) => {
227
+ if (event.key !== 'Escape' || dialog.hidden) return;
228
+
229
+ const trigger = activeView;
230
+ closeDialog();
231
+ trigger?.focus();
232
+ });
233
+ window.addEventListener('resize', closeDialog);
234
+ window.addEventListener('scroll', (event) => {
235
+ if (event.target === dialog || dialog.contains(event.target)) return;
236
+
237
+ closeDialog();
238
+ }, true);
239
+ })();
240
+
241
+ (() => {
242
+ const pageSize = document.querySelector('[data-page-size]');
243
+ const pageJump = document.querySelector('[data-page-jump]');
244
+ const pagination = document.querySelector('.table-pagination');
245
+ const tableScroller = document.querySelector('.table-scroll');
246
+ const allowedSizes = ['10', '25', '50', '100'];
247
+ const preferenceKey = 'rdbr.pageSize';
248
+ const preferenceCookie = 'rdbr_page_size';
249
+ const renderedPageSize = Number(pageSize?.value || '25');
250
+ const scrollKey = (url) => `rdbr.tableScroll:${url.pathname}${url.search}`;
251
+
252
+ const preserveHorizontalScroll = (url) => {
253
+ if (!tableScroller) return;
254
+
255
+ window.sessionStorage.setItem(scrollKey(url), String(tableScroller.scrollLeft));
256
+ };
257
+
258
+ if (tableScroller) {
259
+ const key = scrollKey(window.location);
260
+ const savedScroll = window.sessionStorage.getItem(key);
261
+ if (savedScroll !== null) {
262
+ window.sessionStorage.removeItem(key);
263
+ tableScroller.scrollLeft = Number(savedScroll);
264
+ delete document.documentElement.dataset.restoreTableScroll;
265
+ }
266
+ }
267
+
268
+ const rememberPageSize = (value) => {
269
+ window.localStorage.setItem(preferenceKey, value);
270
+ document.cookie = `${preferenceCookie}=${value}; Path=/; Max-Age=31536000; SameSite=Lax`;
271
+ };
272
+
273
+ const navigate = (changes, preserveScroll = false) => {
274
+ const url = new URL(window.location.href);
275
+ Object.entries(changes).forEach(([name, value]) => url.searchParams.set(name, value));
276
+ if (preserveScroll) preserveHorizontalScroll(url);
277
+ window.location.assign(url);
278
+ };
279
+
280
+ const pageContainingCurrentTopRow = (newSize) => {
281
+ const url = new URL(window.location.href);
282
+ const currentPage = Number(url.searchParams.get('page') || '1');
283
+ const topRowOffset = (currentPage - 1) * renderedPageSize;
284
+ return String(Math.floor(topRowOffset / Number(newSize)) + 1);
285
+ };
286
+
287
+ if (pageSize) {
288
+ const preferred = window.localStorage.getItem(preferenceKey);
289
+ const current = new URL(window.location.href).searchParams.get('per_page');
290
+ if (allowedSizes.includes(current)) {
291
+ rememberPageSize(current);
292
+ } else if (allowedSizes.includes(preferred)) {
293
+ rememberPageSize(preferred);
294
+ }
295
+ if (!current && allowedSizes.includes(preferred) && String(renderedPageSize) !== preferred) {
296
+ navigate({ per_page: preferred, page: pageContainingCurrentTopRow(preferred) });
297
+ return;
298
+ }
299
+
300
+ pageSize.addEventListener('change', () => {
301
+ rememberPageSize(pageSize.value);
302
+ navigate({ per_page: pageSize.value, page: pageContainingCurrentTopRow(pageSize.value) }, true);
303
+ });
304
+ }
305
+
306
+ pagination?.addEventListener('click', (event) => {
307
+ const link = event.target.closest('a');
308
+ if (!link || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
309
+
310
+ preserveHorizontalScroll(new URL(link.href));
311
+ });
312
+
313
+ if (pageJump) {
314
+ const goToPage = () => {
315
+ const requestedPage = Number(pageJump.value);
316
+ const lastPage = Number(pageJump.dataset.lastPage);
317
+ if (!Number.isInteger(requestedPage) || requestedPage < 1 || requestedPage > lastPage) return;
318
+
319
+ navigate({ page: String(requestedPage) }, true);
320
+ };
321
+ pageJump.addEventListener('change', goToPage);
322
+ pageJump.addEventListener('keydown', (event) => {
323
+ if (event.key === 'Enter') goToPage();
324
+ });
325
+ }
326
+ })();
327
+
328
+ (() => {
329
+ const trigger = document.querySelector('[data-column-picker-trigger]');
330
+ const picker = document.querySelector('#column-picker');
331
+ const controls = picker?.querySelector('[data-column-picker-form]');
332
+ const list = picker?.querySelector('.column-picker-list');
333
+ const section = document.querySelector('[data-column-preference-cookie]');
334
+ if (!trigger || !picker || !controls || !list || !section) return;
335
+
336
+ const cookieName = section.dataset.columnPreferenceCookie;
337
+ const storageKey = section.dataset.columnPreferenceStorage;
338
+ const restoreKey = `${storageKey}.restoreAttempted`;
339
+ const cookieExists = document.cookie.split('; ').some((cookie) => cookie.startsWith(`${cookieName}=`));
340
+ const storedPreference = window.localStorage.getItem(storageKey);
341
+ if (storedPreference && !cookieExists && !window.sessionStorage.getItem(restoreKey)) {
342
+ window.sessionStorage.setItem(restoreKey, 'true');
343
+ document.cookie = `${cookieName}=${encodeURIComponent(storedPreference)}; Path=/; Max-Age=31536000; SameSite=Lax`;
344
+ window.location.reload();
345
+ return;
346
+ }
347
+ if (cookieExists) window.sessionStorage.removeItem(restoreKey);
348
+
349
+ const close = () => {
350
+ picker.hidden = true;
351
+ trigger.setAttribute('aria-expanded', 'false');
352
+ };
353
+ const position = () => {
354
+ const triggerBounds = trigger.getBoundingClientRect();
355
+ const pickerBounds = picker.getBoundingClientRect();
356
+ const margin = 8;
357
+ const left = Math.max(margin, Math.min(triggerBounds.right - pickerBounds.width,
358
+ window.innerWidth - pickerBounds.width - margin));
359
+ picker.style.left = `${left}px`;
360
+ picker.style.top = `${Math.max(margin, Math.min(triggerBounds.bottom + margin,
361
+ window.innerHeight - pickerBounds.height - margin))}px`;
362
+ };
363
+ const open = () => {
364
+ picker.hidden = false;
365
+ trigger.setAttribute('aria-expanded', 'true');
366
+ position();
367
+ picker.querySelector('input')?.focus();
368
+ };
369
+ const currentPreference = () => {
370
+ const rows = Array.from(list.querySelectorAll('[data-column-name]'));
371
+ const checked = rows.filter((row) => row.querySelector('input').checked);
372
+ if (checked.length === 0 && rows[0]) rows[0].querySelector('input').checked = true;
373
+ return {
374
+ order: rows.map((row) => row.dataset.columnName),
375
+ hidden: rows.filter((row) => !row.querySelector('input').checked)
376
+ .map((row) => row.dataset.columnName)
377
+ };
378
+ };
379
+ const applyToTable = (preference) => {
380
+ document.querySelectorAll('.data-table').forEach((table) => {
381
+ const headingRow = table.querySelector('thead tr');
382
+ const headings = new Map(Array.from(headingRow?.querySelectorAll('[data-column]') || [])
383
+ .map((cell) => [cell.dataset.column, cell]));
384
+ preference.order.forEach((name) => {
385
+ const heading = headings.get(name);
386
+ if (heading) headingRow.append(heading);
387
+ });
388
+ headings.forEach((heading, name) => { heading.hidden = preference.hidden.includes(name); });
389
+
390
+ table.querySelectorAll('tbody tr').forEach((row) => {
391
+ const cells = new Map(Array.from(row.querySelectorAll('[data-column-cell]'))
392
+ .map((cell) => [cell.dataset.columnCell, cell]));
393
+ preference.order.forEach((name) => {
394
+ const cell = cells.get(name);
395
+ if (cell) row.append(cell);
396
+ });
397
+ cells.forEach((cell, name) => { cell.hidden = preference.hidden.includes(name); });
398
+ });
399
+ });
400
+ window.dispatchEvent(new Event('resize'));
401
+ };
402
+ const save = () => {
403
+ const preference = currentPreference();
404
+ const serialized = JSON.stringify(preference);
405
+ window.localStorage.setItem(storageKey, serialized);
406
+ document.cookie = `${cookieName}=${encodeURIComponent(serialized)}; Path=/; Max-Age=31536000; SameSite=Lax`;
407
+ applyToTable(preference);
408
+ };
409
+
410
+ trigger.addEventListener('click', () => { if (picker.hidden) open(); else close(); });
411
+ picker.querySelector('[data-close-column-picker]').addEventListener('click', close);
412
+ picker.querySelector('[data-show-all-columns]').addEventListener('click', () => {
413
+ list.querySelectorAll('input').forEach((input) => { input.checked = true; });
414
+ save();
415
+ });
416
+ picker.querySelector('[data-reset-columns]').addEventListener('click', () => {
417
+ window.localStorage.removeItem(storageKey);
418
+ document.cookie = `${cookieName}=; Path=/; Max-Age=0; SameSite=Lax`;
419
+ window.location.reload();
420
+ });
421
+ picker.addEventListener('click', (event) => {
422
+ const button = event.target.closest('[data-move-column]');
423
+ if (!button) return;
424
+
425
+ const row = button.closest('[data-column-name]');
426
+ if (button.dataset.moveColumn === 'up' && row.previousElementSibling) list.insertBefore(row, row.previousElementSibling);
427
+ if (button.dataset.moveColumn === 'down' && row.nextElementSibling) list.insertBefore(row.nextElementSibling, row);
428
+ save();
429
+ });
430
+ controls.addEventListener('change', (event) => {
431
+ if (!event.target.matches('input[type="checkbox"]')) return;
432
+
433
+ const checked = list.querySelectorAll('input[type="checkbox"]:checked');
434
+ if (checked.length === 0) event.target.checked = true;
435
+ save();
436
+ });
437
+
438
+ let dragged;
439
+ list.addEventListener('dragstart', (event) => {
440
+ dragged = event.target.closest('[data-column-name]');
441
+ dragged?.classList.add('dragging');
442
+ });
443
+ list.addEventListener('dragover', (event) => {
444
+ event.preventDefault();
445
+ const target = event.target.closest('[data-column-name]');
446
+ if (!dragged || !target || dragged === target) return;
447
+
448
+ const after = event.clientY > target.getBoundingClientRect().top + target.offsetHeight / 2;
449
+ list.insertBefore(dragged, after ? target.nextElementSibling : target);
450
+ });
451
+ list.addEventListener('dragend', () => {
452
+ dragged?.classList.remove('dragging');
453
+ dragged = undefined;
454
+ save();
455
+ });
456
+ document.addEventListener('click', (event) => {
457
+ if (!picker.hidden && !picker.contains(event.target) && !trigger.contains(event.target)) close();
458
+ });
459
+ document.addEventListener('keydown', (event) => {
460
+ if (event.key !== 'Escape' || picker.hidden) return;
461
+
462
+ close();
463
+ trigger.focus();
464
+ });
465
+ const reposition = () => { if (!picker.hidden) position(); };
466
+ window.addEventListener('resize', reposition);
467
+ window.addEventListener('scroll', reposition, true);
468
+ })();
469
+
470
+ (() => {
471
+ const scroller = document.querySelector('.table-scroll');
472
+ const table = scroller?.querySelector('.data-table');
473
+ const heading = table?.querySelector('thead');
474
+ const toolbar = document.querySelector('.table-page-heading');
475
+ const filters = document.querySelector('.active-filters');
476
+
477
+ if (!scroller || !table || !heading) return;
478
+
479
+ const container = document.createElement('div');
480
+ const floatingTable = document.createElement('table');
481
+ const floatingHeading = heading.cloneNode(true);
482
+ const floatingScroll = document.createElement('div');
483
+ const floatingScrollWidth = document.createElement('div');
484
+ container.className = 'floating-table-header';
485
+ floatingScroll.className = 'floating-horizontal-scroll';
486
+ floatingScrollWidth.className = 'floating-horizontal-scroll-width';
487
+ floatingTable.className = 'data-table';
488
+ floatingTable.append(floatingHeading);
489
+ container.append(floatingTable);
490
+ floatingScroll.append(floatingScrollWidth);
491
+ scroller.before(floatingScroll);
492
+ document.body.append(container);
493
+
494
+ const stickyOffset = () => {
495
+ const toolbarHeight = toolbar?.getBoundingClientRect().height || 0;
496
+ const filtersHeight = filters?.getBoundingClientRect().height || 0;
497
+ const scrollHeight = floatingScroll.classList.contains('visible') ? floatingScroll.offsetHeight : 0;
498
+ document.documentElement.style.setProperty('--table-toolbar-height', `${toolbarHeight}px`);
499
+ floatingScroll.style.top = `${toolbarHeight + filtersHeight}px`;
500
+ container.style.top = `${toolbarHeight + filtersHeight + scrollHeight}px`;
501
+ return toolbarHeight + filtersHeight + scrollHeight;
502
+ };
503
+
504
+ const synchronizeGeometry = () => {
505
+ const scrollerBounds = scroller.getBoundingClientRect();
506
+ const cells = Array.from(heading.querySelectorAll('th'));
507
+ const floatingCells = Array.from(floatingHeading.querySelectorAll('th'));
508
+
509
+ container.style.left = `${scrollerBounds.left}px`;
510
+ container.style.width = `${scrollerBounds.width}px`;
511
+ floatingScroll.style.width = `${scrollerBounds.width}px`;
512
+ floatingScrollWidth.style.width = `${table.scrollWidth}px`;
513
+ floatingTable.style.width = `${table.scrollWidth}px`;
514
+ const horizontallyScrollable = table.scrollWidth > scroller.clientWidth;
515
+ floatingScroll.classList.toggle('visible', horizontallyScrollable);
516
+ cells.forEach((cell, index) => {
517
+ const width = cell.getBoundingClientRect().width;
518
+ floatingCells[index].style.width = `${width}px`;
519
+ floatingCells[index].style.minWidth = `${width}px`;
520
+ floatingCells[index].style.maxWidth = `${width}px`;
521
+ });
522
+ };
523
+
524
+ const update = () => {
525
+ const bounds = scroller.getBoundingClientRect();
526
+ const headingHeight = heading.getBoundingClientRect().height;
527
+ const offset = stickyOffset();
528
+ const visible = bounds.top <= offset && bounds.bottom > offset + headingHeight;
529
+ container.classList.toggle('visible', visible);
530
+ floatingScroll.scrollLeft = scroller.scrollLeft;
531
+ floatingTable.style.transform = `translateX(${-scroller.scrollLeft}px)`;
532
+
533
+ const firstCell = floatingHeading.querySelector('.record-link-column');
534
+ if (firstCell) firstCell.style.transform = `translateX(${scroller.scrollLeft}px)`;
535
+ };
536
+
537
+ const refresh = () => {
538
+ synchronizeGeometry();
539
+ update();
540
+ };
541
+
542
+ scroller.addEventListener('scroll', update, { passive: true });
543
+ floatingScroll.addEventListener('scroll', () => {
544
+ if (scroller.scrollLeft !== floatingScroll.scrollLeft) scroller.scrollLeft = floatingScroll.scrollLeft;
545
+ }, { passive: true });
546
+ window.addEventListener('scroll', update, { passive: true });
547
+ window.addEventListener('resize', refresh);
548
+ if ('ResizeObserver' in window) {
549
+ const observer = new ResizeObserver(refresh);
550
+ if (toolbar) observer.observe(toolbar);
551
+ if (filters) observer.observe(filters);
552
+ }
553
+ refresh();
554
+ })();
555
+
556
+ (() => {
557
+ const numberFormatter = new Intl.NumberFormat();
558
+ const schemas = Array.from(document.querySelectorAll('.relation-schema'));
559
+ const closeSchema = (details) => { details.open = false; };
560
+ const closeSchemasExcept = (active) => {
561
+ schemas.forEach((details) => {
562
+ if (details !== active) closeSchema(details);
563
+ });
564
+ };
565
+
566
+ schemas.forEach((details) => {
567
+ details.addEventListener('toggle', async () => {
568
+ if (!details.open) return;
569
+
570
+ closeSchemasExcept(details);
571
+ if (details.dataset.rowCountLoaded) return;
572
+
573
+ const output = details.querySelector('.schema-row-count');
574
+ output.textContent = 'Rows: counting…';
575
+ details.dataset.rowCountLoaded = 'true';
576
+ try {
577
+ const response = await fetch(details.dataset.rowCountUrl, { headers: { Accept: 'application/json' } });
578
+ if (!response.ok) throw new Error(`Count failed with status ${response.status}`);
579
+
580
+ const count = (await response.json()).total_count;
581
+ output.textContent = count === null ? 'Rows: unavailable' : `Rows: ${numberFormatter.format(count)}`;
582
+ } catch (_error) {
583
+ output.textContent = 'Rows: unavailable';
584
+ }
585
+ });
586
+ });
587
+
588
+ document.addEventListener('click', (event) => {
589
+ const closeButton = event.target.closest('[data-close-schema]');
590
+ if (closeButton) {
591
+ closeSchema(closeButton.closest('.relation-schema'));
592
+ return;
593
+ }
594
+
595
+ schemas.forEach((details) => {
596
+ if (details.open && !details.contains(event.target)) closeSchema(details);
597
+ });
598
+ });
599
+ document.addEventListener('keydown', (event) => {
600
+ if (event.key !== 'Escape') return;
601
+
602
+ const active = schemas.find((details) => details.open);
603
+ if (!active) return;
604
+
605
+ closeSchema(active);
606
+ active.querySelector('summary').focus();
607
+ });
608
+ window.addEventListener('resize', () => schemas.forEach(closeSchema));
609
+ })();