pglens 1.0.0 → 2.0.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.
- package/CHANGELOG.md +37 -7
- package/README.md +38 -145
- package/bin/pglens +143 -7
- package/client/app.js +1141 -79
- package/client/index.html +133 -35
- package/client/styles.css +980 -49
- package/package.json +1 -1
- package/src/db/connection.js +163 -27
- package/src/routes/api.js +288 -9
- package/src/server.js +72 -30
- package/pglens-1.0.0.tgz +0 -0
package/client/app.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Main client-side application for viewing PostgreSQL database tables.
|
|
5
5
|
* Features:
|
|
6
|
+
* - Multi-database connection support
|
|
6
7
|
* - Multi-tab table viewing
|
|
7
8
|
* - Client-side sorting and column management
|
|
8
9
|
* - Cursor-based pagination for large tables
|
|
@@ -11,13 +12,18 @@
|
|
|
11
12
|
*/
|
|
12
13
|
|
|
13
14
|
// Application state
|
|
14
|
-
let
|
|
15
|
+
let connections = []; // Array of active connections: { id, name, connectionString, sslMode }
|
|
16
|
+
let activeConnectionId = null; // Currently active connection ID
|
|
17
|
+
let tabs = []; // Array of tab objects: { connectionId, tableName, page, totalCount, sortColumn, sortDirection, data, hiddenColumns, columnWidths, cursor, cursorHistory, hasPrimaryKey, isApproximate }
|
|
15
18
|
let activeTabIndex = -1; // Currently active tab index
|
|
16
|
-
let allTables = []; // All available tables from the database
|
|
19
|
+
let allTables = []; // All available tables from the current database connection
|
|
17
20
|
let searchQuery = ''; // Current search filter for tables
|
|
18
21
|
let currentTheme = 'system'; // Current theme: 'light', 'dark', or 'system'
|
|
22
|
+
|
|
23
|
+
// UI Elements
|
|
19
24
|
const sidebar = document.getElementById('sidebar');
|
|
20
|
-
const sidebarContent =
|
|
25
|
+
const sidebarContent = document.getElementById('sidebarContent');
|
|
26
|
+
const connectionsList = document.getElementById('connectionsList');
|
|
21
27
|
const tableCount = document.getElementById('tableCount');
|
|
22
28
|
const sidebarToggle = document.getElementById('sidebarToggle');
|
|
23
29
|
const sidebarSearch = document.getElementById('sidebarSearch');
|
|
@@ -27,6 +33,25 @@ const tabsContainer = document.getElementById('tabsContainer');
|
|
|
27
33
|
const tabsBar = document.getElementById('tabsBar');
|
|
28
34
|
const tableView = document.getElementById('tableView');
|
|
29
35
|
const pagination = document.getElementById('pagination');
|
|
36
|
+
const addConnectionButton = document.getElementById('addConnectionButton');
|
|
37
|
+
|
|
38
|
+
// Connection UI Elements
|
|
39
|
+
const connectionDialog = document.getElementById('connectionDialog');
|
|
40
|
+
const closeConnectionDialogButton = document.getElementById('closeConnectionDialog');
|
|
41
|
+
const connectionNameInput = document.getElementById('connectionName');
|
|
42
|
+
const connectionUrlInput = document.getElementById('connectionUrl');
|
|
43
|
+
const connectionTabs = document.querySelectorAll('.connection-type-tab');
|
|
44
|
+
const modeUrl = document.getElementById('modeUrl');
|
|
45
|
+
const modeParams = document.getElementById('modeParams');
|
|
46
|
+
const connHost = document.getElementById('connHost');
|
|
47
|
+
const connPort = document.getElementById('connPort');
|
|
48
|
+
const connDatabase = document.getElementById('connDatabase');
|
|
49
|
+
const connUser = document.getElementById('connUser');
|
|
50
|
+
const connPassword = document.getElementById('connPassword');
|
|
51
|
+
const sslModeSelect = document.getElementById('sslMode');
|
|
52
|
+
const connectButton = document.getElementById('connectButton');
|
|
53
|
+
const connectionError = document.getElementById('connectionError');
|
|
54
|
+
const loadingOverlay = document.getElementById('loadingOverlay');
|
|
30
55
|
|
|
31
56
|
/**
|
|
32
57
|
* Initialize the application when DOM is ready.
|
|
@@ -34,7 +59,39 @@ const pagination = document.getElementById('pagination');
|
|
|
34
59
|
*/
|
|
35
60
|
document.addEventListener('DOMContentLoaded', () => {
|
|
36
61
|
initTheme();
|
|
37
|
-
|
|
62
|
+
fetchConnections(); // Check status and load connections
|
|
63
|
+
|
|
64
|
+
// Connection Event Listeners
|
|
65
|
+
connectButton.addEventListener('click', handleConnect);
|
|
66
|
+
addConnectionButton.addEventListener('click', () => showConnectionDialog(true));
|
|
67
|
+
closeConnectionDialogButton.addEventListener('click', hideConnectionDialog);
|
|
68
|
+
|
|
69
|
+
connectionTabs.forEach(tab => {
|
|
70
|
+
tab.addEventListener('click', () => {
|
|
71
|
+
// Switch active tab
|
|
72
|
+
connectionTabs.forEach(t => t.classList.remove('active'));
|
|
73
|
+
tab.classList.add('active');
|
|
74
|
+
|
|
75
|
+
// Show content
|
|
76
|
+
const target = tab.dataset.target;
|
|
77
|
+
if (target === 'url') {
|
|
78
|
+
modeUrl.style.display = 'block';
|
|
79
|
+
modeParams.style.display = 'none';
|
|
80
|
+
connectionDialog.dataset.inputMode = 'url';
|
|
81
|
+
} else {
|
|
82
|
+
modeUrl.style.display = 'none';
|
|
83
|
+
modeParams.style.display = 'block';
|
|
84
|
+
connectionDialog.dataset.inputMode = 'params';
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
// Allow Enter key to submit connection form
|
|
90
|
+
connectionUrlInput.addEventListener('keypress', (e) => {
|
|
91
|
+
if (e.key === 'Enter') {
|
|
92
|
+
handleConnect();
|
|
93
|
+
}
|
|
94
|
+
});
|
|
38
95
|
|
|
39
96
|
sidebarToggle.addEventListener('click', () => {
|
|
40
97
|
if (tabs.length > 0) {
|
|
@@ -129,7 +186,7 @@ function updateThemeIcon() {
|
|
|
129
186
|
}
|
|
130
187
|
|
|
131
188
|
function updateSidebarToggleState() {
|
|
132
|
-
if (tabs.length === 0) {
|
|
189
|
+
if (tabs.length === 0 && connections.length === 0) {
|
|
133
190
|
sidebarToggle.disabled = true;
|
|
134
191
|
sidebarToggle.classList.add('disabled');
|
|
135
192
|
sidebar.classList.remove('minimized');
|
|
@@ -140,13 +197,397 @@ function updateSidebarToggleState() {
|
|
|
140
197
|
}
|
|
141
198
|
|
|
142
199
|
/**
|
|
143
|
-
*
|
|
200
|
+
* Fetch active connections from API.
|
|
201
|
+
*/
|
|
202
|
+
async function fetchConnections() {
|
|
203
|
+
try {
|
|
204
|
+
const response = await fetch('/api/connections');
|
|
205
|
+
const data = await response.json();
|
|
206
|
+
|
|
207
|
+
connections = data.connections || [];
|
|
208
|
+
|
|
209
|
+
if (connections.length > 0) {
|
|
210
|
+
if (!activeConnectionId || !connections.find(c => c.id === activeConnectionId)) {
|
|
211
|
+
activeConnectionId = connections[0].id;
|
|
212
|
+
}
|
|
213
|
+
renderConnectionsList();
|
|
214
|
+
loadTables();
|
|
215
|
+
hideConnectionDialog();
|
|
216
|
+
} else {
|
|
217
|
+
showConnectionDialog(false);
|
|
218
|
+
}
|
|
219
|
+
} catch (error) {
|
|
220
|
+
console.error('Failed to fetch connections:', error);
|
|
221
|
+
showConnectionDialog(false);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Render the list of active connections in the sidebar.
|
|
227
|
+
*/
|
|
228
|
+
function renderConnectionsList() {
|
|
229
|
+
connectionsList.innerHTML = '';
|
|
230
|
+
|
|
231
|
+
connections.forEach(conn => {
|
|
232
|
+
const li = document.createElement('li');
|
|
233
|
+
li.className = 'connection-item';
|
|
234
|
+
if (conn.id === activeConnectionId) {
|
|
235
|
+
li.classList.add('active');
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const nameSpan = document.createElement('span');
|
|
239
|
+
nameSpan.className = 'connection-name';
|
|
240
|
+
nameSpan.textContent = conn.name;
|
|
241
|
+
nameSpan.title = 'Click to edit connection';
|
|
242
|
+
nameSpan.style.cursor = 'pointer';
|
|
243
|
+
nameSpan.addEventListener('click', (e) => {
|
|
244
|
+
e.stopPropagation();
|
|
245
|
+
handleConnectionEdit(conn);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
// Add click event for the li to switch connection if not clicking name or close
|
|
249
|
+
li.addEventListener('click', (e) => {
|
|
250
|
+
if (e.target !== nameSpan && e.target !== disconnectBtn) {
|
|
251
|
+
switchConnection(conn.id);
|
|
252
|
+
}
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
const disconnectBtn = document.createElement('button');
|
|
256
|
+
disconnectBtn.className = 'connection-disconnect';
|
|
257
|
+
disconnectBtn.innerHTML = '×';
|
|
258
|
+
disconnectBtn.title = 'Disconnect';
|
|
259
|
+
disconnectBtn.addEventListener('click', (e) => {
|
|
260
|
+
e.stopPropagation();
|
|
261
|
+
handleDisconnect(conn.id);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
li.appendChild(nameSpan);
|
|
265
|
+
li.appendChild(disconnectBtn);
|
|
266
|
+
connectionsList.appendChild(li);
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Switch the active connection.
|
|
272
|
+
* @param {string} connectionId - The connection ID to switch to
|
|
273
|
+
*/
|
|
274
|
+
function switchConnection(connectionId) {
|
|
275
|
+
if (activeConnectionId === connectionId) return;
|
|
276
|
+
|
|
277
|
+
activeConnectionId = connectionId;
|
|
278
|
+
renderConnectionsList();
|
|
279
|
+
loadTables();
|
|
280
|
+
|
|
281
|
+
// Clear tables view if no tab from this connection is active
|
|
282
|
+
const currentTab = getActiveTab();
|
|
283
|
+
if (currentTab && currentTab.connectionId !== activeConnectionId) {
|
|
284
|
+
// Try to find the last active tab for this connection
|
|
285
|
+
const tabIndex = tabs.findIndex(t => t.connectionId === activeConnectionId);
|
|
286
|
+
if (tabIndex !== -1) {
|
|
287
|
+
switchToTab(tabIndex);
|
|
288
|
+
} else {
|
|
289
|
+
// No tabs for this connection, show empty state
|
|
290
|
+
tableView.innerHTML = '<div class="empty-state"><p>Select a table from the sidebar to view its data</p></div>';
|
|
291
|
+
pagination.style.display = 'none';
|
|
292
|
+
|
|
293
|
+
// Deselect all tabs visually
|
|
294
|
+
const tabElements = tabsBar.querySelectorAll('.tab');
|
|
295
|
+
tabElements.forEach(el => el.classList.remove('active'));
|
|
296
|
+
activeTabIndex = -1;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Handle database connection.
|
|
303
|
+
*/
|
|
304
|
+
async function handleConnect() {
|
|
305
|
+
let url = '';
|
|
306
|
+
const connectionName = connectionNameInput.value.trim();
|
|
307
|
+
const sslMode = sslModeSelect.value;
|
|
308
|
+
const inputMode = connectionDialog.dataset.inputMode || 'url';
|
|
309
|
+
|
|
310
|
+
if (inputMode === 'url') {
|
|
311
|
+
url = connectionUrlInput.value.trim();
|
|
312
|
+
} else {
|
|
313
|
+
// Build URL from params
|
|
314
|
+
const host = connHost.value.trim() || 'localhost';
|
|
315
|
+
const port = connPort.value.trim() || '5432';
|
|
316
|
+
const database = connDatabase.value.trim() || 'postgres';
|
|
317
|
+
const user = connUser.value.trim();
|
|
318
|
+
const password = connPassword.value;
|
|
319
|
+
|
|
320
|
+
if (!user) {
|
|
321
|
+
showConnectionError('Username is required');
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
url = buildConnectionString(user, password, host, port, database);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
if (!url) {
|
|
329
|
+
showConnectionError('Please enter a connection URL');
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// Validate URL format
|
|
334
|
+
try {
|
|
335
|
+
if (!url.startsWith('postgres://') && !url.startsWith('postgresql://')) {
|
|
336
|
+
showConnectionError('URL must start with postgres:// or postgresql://');
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const urlObj = new URL(url);
|
|
341
|
+
if (!urlObj.pathname || urlObj.pathname === '/') {
|
|
342
|
+
showConnectionError('URL must include a database name');
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
} catch (e) {
|
|
346
|
+
showConnectionError('Invalid URL format');
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
try {
|
|
351
|
+
setConnectingState(true);
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
let urlPath = '/api/connect';
|
|
355
|
+
let method = 'POST';
|
|
356
|
+
|
|
357
|
+
if (connectionDialog.dataset.mode === 'edit') {
|
|
358
|
+
const id = connectionDialog.dataset.connectionId;
|
|
359
|
+
urlPath = `/api/connections/${id}`;
|
|
360
|
+
method = 'PUT';
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const response = await fetch(urlPath, {
|
|
364
|
+
method: method,
|
|
365
|
+
headers: { 'Content-Type': 'application/json' },
|
|
366
|
+
body: JSON.stringify({ url, sslMode, name: connectionName || undefined })
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
const data = await response.json();
|
|
370
|
+
|
|
371
|
+
if (response.ok) {
|
|
372
|
+
if (data.connected || data.updated) {
|
|
373
|
+
// If updated, update local array
|
|
374
|
+
if (data.updated) {
|
|
375
|
+
const index = connections.findIndex(c => c.id === data.connectionId);
|
|
376
|
+
if (index !== -1) {
|
|
377
|
+
connections[index] = {
|
|
378
|
+
...connections[index],
|
|
379
|
+
name: data.name,
|
|
380
|
+
connectionString: url,
|
|
381
|
+
sslMode: sslMode
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
} else {
|
|
385
|
+
// New connection
|
|
386
|
+
// Check if this connection ID already exists in our list (backend might return existing one)
|
|
387
|
+
const existingIndex = connections.findIndex(c => c.id === data.connectionId);
|
|
388
|
+
if (existingIndex === -1) {
|
|
389
|
+
connections.push({
|
|
390
|
+
id: data.connectionId,
|
|
391
|
+
name: data.name,
|
|
392
|
+
connectionString: url,
|
|
393
|
+
sslMode: sslMode
|
|
394
|
+
});
|
|
395
|
+
} else {
|
|
396
|
+
console.log('Connection already exists, switching to it');
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
activeConnectionId = data.connectionId;
|
|
401
|
+
|
|
402
|
+
renderConnectionsList();
|
|
403
|
+
loadTables();
|
|
404
|
+
hideConnectionDialog();
|
|
405
|
+
// Don't clear inputs here, will clear on show
|
|
406
|
+
}
|
|
407
|
+
} else {
|
|
408
|
+
showConnectionError(data.error || 'Failed to connect');
|
|
409
|
+
}
|
|
410
|
+
} catch (error) {
|
|
411
|
+
showConnectionError(error.message);
|
|
412
|
+
} finally {
|
|
413
|
+
setConnectingState(false);
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* Handle database disconnection.
|
|
419
|
+
* @param {string} connectionId - ID of connection to disconnect
|
|
420
|
+
*/
|
|
421
|
+
async function handleDisconnect(connectionId) {
|
|
422
|
+
if (!confirm('Are you sure you want to disconnect? Associated tabs will be closed.')) {
|
|
423
|
+
return;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
try {
|
|
427
|
+
const response = await fetch('/api/disconnect', {
|
|
428
|
+
method: 'POST',
|
|
429
|
+
headers: { 'Content-Type': 'application/json' },
|
|
430
|
+
body: JSON.stringify({ connectionId })
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
if (response.ok) {
|
|
434
|
+
// Remove from local state
|
|
435
|
+
connections = connections.filter(c => c.id !== connectionId);
|
|
436
|
+
|
|
437
|
+
// Close associated tabs
|
|
438
|
+
const tabsToRemove = [];
|
|
439
|
+
tabs.forEach((tab, index) => {
|
|
440
|
+
if (tab.connectionId === connectionId) {
|
|
441
|
+
tabsToRemove.push(index);
|
|
442
|
+
}
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
// Remove tabs in reverse order to maintain indices
|
|
446
|
+
for (let i = tabsToRemove.length - 1; i >= 0; i--) {
|
|
447
|
+
closeTab(tabsToRemove[i]);
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
if (connections.length === 0) {
|
|
451
|
+
activeConnectionId = null;
|
|
452
|
+
sidebarContent.innerHTML = '';
|
|
453
|
+
tableCount.textContent = '0';
|
|
454
|
+
renderConnectionsList();
|
|
455
|
+
showConnectionDialog(false);
|
|
456
|
+
} else {
|
|
457
|
+
if (activeConnectionId === connectionId) {
|
|
458
|
+
// Switch to another connection (the first one)
|
|
459
|
+
activeConnectionId = connections[0].id;
|
|
460
|
+
loadTables();
|
|
461
|
+
}
|
|
462
|
+
renderConnectionsList();
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
} catch (error) {
|
|
466
|
+
console.error('Failed to disconnect:', error);
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
function showConnectionDialog(allowClose, editMode = false, connection = null) {
|
|
473
|
+
connectionDialog.style.display = 'flex';
|
|
474
|
+
connectionError.style.display = 'none';
|
|
475
|
+
|
|
476
|
+
const title = connectionDialog.querySelector('h2');
|
|
477
|
+
title.textContent = editMode ? 'Edit Connection' : 'Connect to Database';
|
|
478
|
+
connectButton.textContent = editMode ? 'Save' : 'Connect';
|
|
479
|
+
|
|
480
|
+
connectionDialog.dataset.mode = editMode ? 'edit' : 'add';
|
|
481
|
+
|
|
482
|
+
if (editMode && connection) {
|
|
483
|
+
connectionDialog.dataset.connectionId = connection.id;
|
|
484
|
+
connectionNameInput.value = connection.name || '';
|
|
485
|
+
sslModeSelect.value = connection.sslMode || 'prefer';
|
|
486
|
+
|
|
487
|
+
// Try to parse URL to populate params
|
|
488
|
+
const parsed = parseConnectionString(connection.connectionString);
|
|
489
|
+
if (parsed) {
|
|
490
|
+
connHost.value = parsed.host;
|
|
491
|
+
connPort.value = parsed.port;
|
|
492
|
+
connDatabase.value = parsed.database;
|
|
493
|
+
connUser.value = parsed.user;
|
|
494
|
+
connPassword.value = parsed.password;
|
|
495
|
+
}
|
|
496
|
+
connectionUrlInput.value = connection.connectionString || '';
|
|
497
|
+
} else {
|
|
498
|
+
delete connectionDialog.dataset.connectionId;
|
|
499
|
+
connectionUrlInput.value = '';
|
|
500
|
+
connectionNameInput.value = '';
|
|
501
|
+
sslModeSelect.value = 'prefer';
|
|
502
|
+
|
|
503
|
+
// Reset params
|
|
504
|
+
connHost.value = 'localhost';
|
|
505
|
+
connPort.value = '5432';
|
|
506
|
+
connDatabase.value = 'postgres';
|
|
507
|
+
connUser.value = '';
|
|
508
|
+
connPassword.value = '';
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
// Reset tabs to Params mode by default (since we swapped buttons, tab[0] is Params)
|
|
512
|
+
connectionTabs.forEach(t => t.classList.remove('active'));
|
|
513
|
+
connectionTabs[0].classList.add('active');
|
|
514
|
+
|
|
515
|
+
modeUrl.style.display = 'none';
|
|
516
|
+
modeParams.style.display = 'block';
|
|
517
|
+
connectionDialog.dataset.inputMode = 'params';
|
|
518
|
+
|
|
519
|
+
connHost.focus();
|
|
520
|
+
|
|
521
|
+
if (allowClose && connections.length > 0) {
|
|
522
|
+
closeConnectionDialogButton.style.display = 'block';
|
|
523
|
+
} else {
|
|
524
|
+
closeConnectionDialogButton.style.display = 'none';
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
function buildConnectionString(user, password, host, port, database) {
|
|
529
|
+
let auth = user;
|
|
530
|
+
if (password) {
|
|
531
|
+
auth += `:${encodeURIComponent(password)}`;
|
|
532
|
+
}
|
|
533
|
+
return `postgresql://${auth}@${host}:${port}/${database}`;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
function parseConnectionString(urlStr) {
|
|
537
|
+
try {
|
|
538
|
+
if (!urlStr) return null;
|
|
539
|
+
// Handle cases where protocol might be missing (though validation enforces it)
|
|
540
|
+
if (!urlStr.includes('://')) {
|
|
541
|
+
urlStr = 'postgresql://' + urlStr;
|
|
542
|
+
}
|
|
543
|
+
const url = new URL(urlStr);
|
|
544
|
+
return {
|
|
545
|
+
host: url.hostname || 'localhost',
|
|
546
|
+
port: url.port || '5432',
|
|
547
|
+
database: url.pathname.replace(/^\//, '') || 'postgres',
|
|
548
|
+
user: url.username || '',
|
|
549
|
+
password: url.password || '' // Note: URL decoding happens automatically for username/password properties? Verify.
|
|
550
|
+
// Actually URL properties are usually decoded. decodeURIComponent check might be needed if raw.
|
|
551
|
+
};
|
|
552
|
+
} catch (e) {
|
|
553
|
+
return null;
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
function handleConnectionEdit(connection) {
|
|
558
|
+
showConnectionDialog(true, true, connection);
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
function hideConnectionDialog() {
|
|
562
|
+
connectionDialog.style.display = 'none';
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
function showConnectionError(message) {
|
|
566
|
+
connectionError.textContent = message;
|
|
567
|
+
connectionError.style.display = 'block';
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
function setConnectingState(isConnecting) {
|
|
571
|
+
connectButton.disabled = isConnecting;
|
|
572
|
+
connectButton.textContent = isConnecting ? 'Connecting...' : 'Connect';
|
|
573
|
+
connectionNameInput.disabled = isConnecting;
|
|
574
|
+
connectionUrlInput.disabled = isConnecting;
|
|
575
|
+
sslModeSelect.disabled = isConnecting;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
/**
|
|
579
|
+
* Load all tables from the active database via API.
|
|
144
580
|
* Fetches table list and updates the sidebar.
|
|
145
581
|
*/
|
|
146
582
|
async function loadTables() {
|
|
583
|
+
if (!activeConnectionId) return;
|
|
584
|
+
|
|
147
585
|
try {
|
|
148
586
|
sidebarContent.innerHTML = '<div class="loading">Loading tables...</div>';
|
|
149
|
-
|
|
587
|
+
|
|
588
|
+
const response = await fetch('/api/tables', {
|
|
589
|
+
headers: { 'x-connection-id': activeConnectionId }
|
|
590
|
+
});
|
|
150
591
|
const data = await response.json();
|
|
151
592
|
|
|
152
593
|
if (data.error) {
|
|
@@ -225,7 +666,11 @@ function escapeHtml(text) {
|
|
|
225
666
|
}
|
|
226
667
|
|
|
227
668
|
function updateSidebarActiveState() {
|
|
228
|
-
const
|
|
669
|
+
const activeTab = getActiveTab();
|
|
670
|
+
|
|
671
|
+
// Only highlight sidebar if active tab belongs to active connection
|
|
672
|
+
const activeTable = (activeTab && activeTab.connectionId === activeConnectionId) ? activeTab.tableName : null;
|
|
673
|
+
|
|
229
674
|
if (!activeTable) {
|
|
230
675
|
const tableItems = sidebarContent.querySelectorAll('.table-list li');
|
|
231
676
|
tableItems.forEach(item => item.classList.remove('active'));
|
|
@@ -252,12 +697,18 @@ function updateSidebarActiveState() {
|
|
|
252
697
|
* @param {string} tableName - Name of the table to open
|
|
253
698
|
*/
|
|
254
699
|
function handleTableSelect(tableName) {
|
|
255
|
-
|
|
700
|
+
if (!activeConnectionId) return;
|
|
701
|
+
|
|
702
|
+
// Check if tab already exists for this table AND connection
|
|
703
|
+
const existingTabIndex = tabs.findIndex(tab =>
|
|
704
|
+
tab.tableName === tableName && tab.connectionId === activeConnectionId
|
|
705
|
+
);
|
|
256
706
|
|
|
257
707
|
if (existingTabIndex !== -1) {
|
|
258
708
|
switchToTab(existingTabIndex);
|
|
259
709
|
} else {
|
|
260
710
|
const newTab = {
|
|
711
|
+
connectionId: activeConnectionId,
|
|
261
712
|
tableName: tableName,
|
|
262
713
|
page: 1,
|
|
263
714
|
totalCount: 0,
|
|
@@ -269,7 +720,8 @@ function handleTableSelect(tableName) {
|
|
|
269
720
|
cursor: null, // Cursor for cursor-based pagination
|
|
270
721
|
cursorHistory: [], // History for backward navigation
|
|
271
722
|
hasPrimaryKey: false, // Whether table has primary key
|
|
272
|
-
isApproximate: false // Whether count is approximate (for large tables)
|
|
723
|
+
isApproximate: false, // Whether count is approximate (for large tables)
|
|
724
|
+
limit: 100 // Rows per page
|
|
273
725
|
};
|
|
274
726
|
tabs.push(newTab);
|
|
275
727
|
activeTabIndex = tabs.length - 1;
|
|
@@ -287,14 +739,12 @@ function switchToTab(index) {
|
|
|
287
739
|
activeTabIndex = index;
|
|
288
740
|
const tab = tabs[activeTabIndex];
|
|
289
741
|
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
}
|
|
297
|
-
});
|
|
742
|
+
// Switch connection if tab belongs to different connection
|
|
743
|
+
if (tab.connectionId !== activeConnectionId) {
|
|
744
|
+
activeConnectionId = tab.connectionId;
|
|
745
|
+
renderConnectionsList();
|
|
746
|
+
loadTables();
|
|
747
|
+
}
|
|
298
748
|
|
|
299
749
|
renderTabs();
|
|
300
750
|
updateSidebarActiveState();
|
|
@@ -312,29 +762,41 @@ function closeTab(index, event) {
|
|
|
312
762
|
event.stopPropagation();
|
|
313
763
|
}
|
|
314
764
|
|
|
315
|
-
if (tabs.length
|
|
316
|
-
|
|
765
|
+
if (index < 0 || index >= tabs.length) return;
|
|
766
|
+
|
|
767
|
+
const tabWasActive = (index === activeTabIndex);
|
|
768
|
+
tabs.splice(index, 1);
|
|
769
|
+
|
|
770
|
+
if (tabs.length === 0) {
|
|
317
771
|
activeTabIndex = -1;
|
|
318
772
|
tabsContainer.style.display = 'none';
|
|
319
773
|
tableView.innerHTML = '<div class="empty-state"><p>Select a table from the sidebar to view its data</p></div>';
|
|
320
774
|
pagination.style.display = 'none';
|
|
321
|
-
|
|
322
|
-
const tableItems = sidebarContent.querySelectorAll('.table-list li');
|
|
323
|
-
tableItems.forEach(item => item.classList.remove('active'));
|
|
324
|
-
|
|
775
|
+
updateSidebarActiveState();
|
|
325
776
|
updateSidebarToggleState();
|
|
326
777
|
return;
|
|
327
778
|
}
|
|
328
779
|
|
|
329
|
-
|
|
330
|
-
|
|
780
|
+
// Adjust activeTabIndex
|
|
331
781
|
if (activeTabIndex >= index) {
|
|
332
782
|
activeTabIndex--;
|
|
333
|
-
|
|
783
|
+
}
|
|
784
|
+
if (activeTabIndex < 0) {
|
|
785
|
+
activeTabIndex = 0;
|
|
334
786
|
}
|
|
335
787
|
|
|
336
|
-
|
|
337
|
-
|
|
788
|
+
// If we closed the active tab, switch to the new active one
|
|
789
|
+
if (tabWasActive) {
|
|
790
|
+
// Ensure index is within bounds
|
|
791
|
+
if (activeTabIndex >= tabs.length) activeTabIndex = tabs.length - 1;
|
|
792
|
+
|
|
793
|
+
const newActiveTab = tabs[activeTabIndex];
|
|
794
|
+
// Switch connection if needed
|
|
795
|
+
if (newActiveTab && newActiveTab.connectionId !== activeConnectionId) {
|
|
796
|
+
activeConnectionId = newActiveTab.connectionId;
|
|
797
|
+
renderConnectionsList();
|
|
798
|
+
loadTables();
|
|
799
|
+
}
|
|
338
800
|
}
|
|
339
801
|
|
|
340
802
|
renderTabs();
|
|
@@ -347,9 +809,7 @@ function closeTab(index, event) {
|
|
|
347
809
|
} else {
|
|
348
810
|
loadTableData();
|
|
349
811
|
}
|
|
350
|
-
|
|
351
812
|
updateSidebarActiveState();
|
|
352
|
-
updateSidebarToggleState();
|
|
353
813
|
}
|
|
354
814
|
}
|
|
355
815
|
|
|
@@ -372,6 +832,14 @@ function renderTabs() {
|
|
|
372
832
|
tabs.forEach((tab, index) => {
|
|
373
833
|
const tabElement = document.createElement('div');
|
|
374
834
|
tabElement.className = `tab ${index === activeTabIndex ? 'active' : ''}`;
|
|
835
|
+
// Add connection indicator for tab if multiple connections exist
|
|
836
|
+
if (connections.length > 1) {
|
|
837
|
+
const conn = connections.find(c => c.id === tab.connectionId);
|
|
838
|
+
if (conn) {
|
|
839
|
+
tabElement.title = `${tab.tableName} (${conn.name})`;
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
|
|
375
843
|
tabElement.addEventListener('click', () => switchToTab(index));
|
|
376
844
|
|
|
377
845
|
const tabLabel = document.createElement('span');
|
|
@@ -396,9 +864,7 @@ function closeAllTabs() {
|
|
|
396
864
|
tableView.innerHTML = '<div class="empty-state"><p>Select a table from the sidebar to view its data</p></div>';
|
|
397
865
|
pagination.style.display = 'none';
|
|
398
866
|
|
|
399
|
-
|
|
400
|
-
tableItems.forEach(item => item.classList.remove('active'));
|
|
401
|
-
|
|
867
|
+
updateSidebarActiveState();
|
|
402
868
|
updateSidebarToggleState();
|
|
403
869
|
}
|
|
404
870
|
|
|
@@ -413,7 +879,8 @@ function handleRefresh() {
|
|
|
413
879
|
|
|
414
880
|
tab.data = null;
|
|
415
881
|
|
|
416
|
-
const
|
|
882
|
+
const refreshButton = document.querySelector('#refreshButton');
|
|
883
|
+
const refreshIcon = refreshButton ? refreshButton.querySelector('.refresh-icon') : null;
|
|
417
884
|
if (refreshIcon) {
|
|
418
885
|
refreshIcon.classList.add('spinning');
|
|
419
886
|
setTimeout(() => {
|
|
@@ -424,6 +891,27 @@ function handleRefresh() {
|
|
|
424
891
|
loadTableData();
|
|
425
892
|
}
|
|
426
893
|
|
|
894
|
+
/**
|
|
895
|
+
* Check if a value is a JSON object or array (JSONB/JSON type).
|
|
896
|
+
* Excludes null, Date objects, and other non-JSON types.
|
|
897
|
+
* @param {*} value - Value to check
|
|
898
|
+
* @returns {boolean} True if value is a JSON object or array
|
|
899
|
+
*/
|
|
900
|
+
function isJsonValue(value) {
|
|
901
|
+
if (value === null || value === undefined) {
|
|
902
|
+
return false;
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
if (typeof value === 'object') {
|
|
906
|
+
if (value instanceof Date) {
|
|
907
|
+
return false;
|
|
908
|
+
}
|
|
909
|
+
return Array.isArray(value) || Object.prototype.toString.call(value) === '[object Object]';
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
return false;
|
|
913
|
+
}
|
|
914
|
+
|
|
427
915
|
/**
|
|
428
916
|
* Load table data for the active tab.
|
|
429
917
|
* Uses cursor-based pagination for tables with primary keys (more efficient for large datasets).
|
|
@@ -434,16 +922,26 @@ async function loadTableData() {
|
|
|
434
922
|
if (!tab) return;
|
|
435
923
|
|
|
436
924
|
try {
|
|
437
|
-
|
|
925
|
+
showLoading();
|
|
926
|
+
// tableView.innerHTML = '<div class="loading-state"><p>Loading data from ' + tab.tableName + '...</p></div>';
|
|
438
927
|
|
|
439
928
|
// Build query with cursor-based pagination if available
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
929
|
+
if (!tab.limit) {
|
|
930
|
+
tab.limit = 100; // Default limit for existing tabs
|
|
931
|
+
}
|
|
932
|
+
let queryString = `page=${tab.page}&limit=${tab.limit}`;
|
|
933
|
+
if (tab.sortColumn) {
|
|
934
|
+
queryString += `&sortColumn=${encodeURIComponent(tab.sortColumn)}&sortDirection=${tab.sortDirection}`;
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
if (tab.hasPrimaryKey && tab.cursor && tab.page > 1 && !tab.sortColumn) {
|
|
938
|
+
// Use cursor for forward navigation (only if using default sort)
|
|
443
939
|
queryString += `&cursor=${encodeURIComponent(tab.cursor)}`;
|
|
444
940
|
}
|
|
445
941
|
|
|
446
|
-
const response = await fetch(`/api/tables/${tab.tableName}?${queryString}
|
|
942
|
+
const response = await fetch(`/api/tables/${tab.tableName}?${queryString}`, {
|
|
943
|
+
headers: { 'x-connection-id': tab.connectionId }
|
|
944
|
+
});
|
|
447
945
|
const data = await response.json();
|
|
448
946
|
|
|
449
947
|
if (data.error) {
|
|
@@ -478,6 +976,8 @@ async function loadTableData() {
|
|
|
478
976
|
} catch (error) {
|
|
479
977
|
tableView.innerHTML = '<div class="error-state"><p>Error: ' + error.message + '</p></div>';
|
|
480
978
|
pagination.style.display = 'none';
|
|
979
|
+
} finally {
|
|
980
|
+
hideLoading();
|
|
481
981
|
}
|
|
482
982
|
}
|
|
483
983
|
|
|
@@ -487,27 +987,59 @@ function renderTable(data) {
|
|
|
487
987
|
|
|
488
988
|
const columns = Object.keys(data.rows[0] || {});
|
|
489
989
|
|
|
990
|
+
if (!tab.limit) {
|
|
991
|
+
tab.limit = 100; // Default limit for existing tabs
|
|
992
|
+
}
|
|
993
|
+
|
|
490
994
|
const tableHeader = document.createElement('div');
|
|
491
995
|
tableHeader.className = 'table-header';
|
|
996
|
+
const startRow = ((tab.page - 1) * tab.limit) + 1;
|
|
997
|
+
const endRow = Math.min(tab.page * tab.limit, tab.totalCount);
|
|
998
|
+
const totalRows = tab.totalCount;
|
|
999
|
+
|
|
492
1000
|
tableHeader.innerHTML = `
|
|
493
1001
|
<div class="table-header-left">
|
|
494
1002
|
<h2>${tab.tableName}</h2>
|
|
495
|
-
<
|
|
496
|
-
<
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
1003
|
+
<div class="table-header-actions">
|
|
1004
|
+
<button class="refresh-button" id="refreshButton" title="Refresh data">
|
|
1005
|
+
<svg class="refresh-icon" width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
1006
|
+
<path d="M8 2V6M8 14V10M2 8H6M10 8H14" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
|
1007
|
+
<path d="M2.5 5.5C3.1 4.2 4.1 3.2 5.4 2.6M13.5 10.5C12.9 11.8 11.9 12.8 10.6 13.4M5.5 2.5C4.2 3.1 3.2 4.1 2.6 5.4M10.5 13.5C11.8 12.9 12.8 11.9 13.4 10.6" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
|
1008
|
+
</svg>
|
|
1009
|
+
<span class="refresh-text">Refresh</span>
|
|
501
1010
|
</button>
|
|
502
|
-
<div class="
|
|
503
|
-
<
|
|
504
|
-
|
|
1011
|
+
<div class="limit-selector">
|
|
1012
|
+
<select id="limitSelect" class="limit-select" title="Rows per page">
|
|
1013
|
+
<option value="25" ${tab.limit === 25 ? 'selected' : ''}>25 rows</option>
|
|
1014
|
+
<option value="50" ${tab.limit === 50 ? 'selected' : ''}>50 rows</option>
|
|
1015
|
+
<option value="100" ${tab.limit === 100 ? 'selected' : ''}>100 rows</option>
|
|
1016
|
+
<option value="200" ${tab.limit === 200 ? 'selected' : ''}>200 rows</option>
|
|
1017
|
+
<option value="500" ${tab.limit === 500 ? 'selected' : ''}>500 rows</option>
|
|
1018
|
+
</select>
|
|
1019
|
+
</div>
|
|
1020
|
+
<div class="column-selector">
|
|
1021
|
+
<button class="column-button" id="columnButton" title="Show/Hide columns">
|
|
1022
|
+
<svg class="column-icon" width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
1023
|
+
<path d="M3 2H13C13.5523 2 14 2.44772 14 3V13C14 13.5523 13.5523 14 13 14H3C2.44772 14 2 13.5523 2 13V3C2 2.44772 2.44772 2 3 2Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
|
1024
|
+
<path d="M6 2V14M10 2V14" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
|
1025
|
+
</svg>
|
|
1026
|
+
<span class="column-label" id="columnLabel">Columns</span>
|
|
1027
|
+
</button>
|
|
1028
|
+
<div class="column-menu" id="columnMenu" style="display: none;">
|
|
1029
|
+
<div class="column-menu-header">Columns</div>
|
|
1030
|
+
<div class="column-menu-options" id="columnMenuOptions"></div>
|
|
1031
|
+
</div>
|
|
505
1032
|
</div>
|
|
506
1033
|
</div>
|
|
507
1034
|
</div>
|
|
508
|
-
<
|
|
509
|
-
|
|
510
|
-
|
|
1035
|
+
<div class="row-info-container">
|
|
1036
|
+
<span class="row-info">
|
|
1037
|
+
<span class="row-info-range">${startRow.toLocaleString()}–${endRow.toLocaleString()}</span>
|
|
1038
|
+
<span class="row-info-separator">of</span>
|
|
1039
|
+
<span class="row-info-total">${totalRows.toLocaleString()}</span>
|
|
1040
|
+
${tab.isApproximate ? '<span class="row-info-approx">(approx.)</span>' : ''}
|
|
1041
|
+
</span>
|
|
1042
|
+
</div>
|
|
511
1043
|
`;
|
|
512
1044
|
|
|
513
1045
|
const refreshButton = tableHeader.querySelector('#refreshButton');
|
|
@@ -515,6 +1047,21 @@ function renderTable(data) {
|
|
|
515
1047
|
refreshButton.addEventListener('click', handleRefresh);
|
|
516
1048
|
}
|
|
517
1049
|
|
|
1050
|
+
const limitSelect = tableHeader.querySelector('#limitSelect');
|
|
1051
|
+
if (limitSelect) {
|
|
1052
|
+
limitSelect.addEventListener('change', (e) => {
|
|
1053
|
+
const newLimit = parseInt(e.target.value, 10);
|
|
1054
|
+
if (tab.limit !== newLimit) {
|
|
1055
|
+
tab.limit = newLimit;
|
|
1056
|
+
tab.page = 1; // Reset to first page when limit changes
|
|
1057
|
+
tab.cursor = null; // Reset cursor
|
|
1058
|
+
tab.cursorHistory = [];
|
|
1059
|
+
tab.data = null; // Clear cache
|
|
1060
|
+
loadTableData();
|
|
1061
|
+
}
|
|
1062
|
+
});
|
|
1063
|
+
}
|
|
1064
|
+
|
|
518
1065
|
if (!tab.hiddenColumns) {
|
|
519
1066
|
tab.hiddenColumns = [];
|
|
520
1067
|
}
|
|
@@ -538,7 +1085,6 @@ function renderTable(data) {
|
|
|
538
1085
|
|
|
539
1086
|
visibleColumns.forEach((column, index) => {
|
|
540
1087
|
const th = document.createElement('th');
|
|
541
|
-
th.textContent = column;
|
|
542
1088
|
th.className = 'sortable resizable';
|
|
543
1089
|
th.dataset.column = column;
|
|
544
1090
|
|
|
@@ -549,6 +1095,78 @@ function renderTable(data) {
|
|
|
549
1095
|
th.style.minWidth = '120px';
|
|
550
1096
|
}
|
|
551
1097
|
|
|
1098
|
+
const columnHeader = document.createElement('div');
|
|
1099
|
+
columnHeader.className = 'column-header';
|
|
1100
|
+
|
|
1101
|
+
// Column name with key badges
|
|
1102
|
+
const columnNameRow = document.createElement('div');
|
|
1103
|
+
columnNameRow.className = 'column-name-row';
|
|
1104
|
+
|
|
1105
|
+
const columnName = document.createElement('div');
|
|
1106
|
+
columnName.className = 'column-name';
|
|
1107
|
+
columnName.textContent = column;
|
|
1108
|
+
columnNameRow.appendChild(columnName);
|
|
1109
|
+
|
|
1110
|
+
const columnMeta = data.columns && data.columns[column] ? data.columns[column] : null;
|
|
1111
|
+
|
|
1112
|
+
let dataType = '';
|
|
1113
|
+
let isPrimaryKey = false;
|
|
1114
|
+
let isForeignKey = false;
|
|
1115
|
+
let foreignKeyRef = null;
|
|
1116
|
+
let isUnique = false;
|
|
1117
|
+
|
|
1118
|
+
if (columnMeta) {
|
|
1119
|
+
if (typeof columnMeta === 'string') {
|
|
1120
|
+
dataType = columnMeta;
|
|
1121
|
+
} else {
|
|
1122
|
+
dataType = columnMeta.dataType || '';
|
|
1123
|
+
isPrimaryKey = columnMeta.isPrimaryKey || false;
|
|
1124
|
+
isForeignKey = columnMeta.isForeignKey || false;
|
|
1125
|
+
foreignKeyRef = columnMeta.foreignKeyRef || null;
|
|
1126
|
+
isUnique = columnMeta.isUnique || false;
|
|
1127
|
+
}
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
const keyBadges = document.createElement('div');
|
|
1131
|
+
keyBadges.className = 'key-badges';
|
|
1132
|
+
|
|
1133
|
+
if (isPrimaryKey) {
|
|
1134
|
+
const pkBadge = document.createElement('span');
|
|
1135
|
+
pkBadge.className = 'key-badge key-badge-pk';
|
|
1136
|
+
pkBadge.textContent = 'PK';
|
|
1137
|
+
pkBadge.title = 'Primary Key';
|
|
1138
|
+
keyBadges.appendChild(pkBadge);
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
if (isForeignKey && foreignKeyRef) {
|
|
1142
|
+
const fkBadge = document.createElement('span');
|
|
1143
|
+
fkBadge.className = 'key-badge key-badge-fk';
|
|
1144
|
+
fkBadge.textContent = 'FK';
|
|
1145
|
+
fkBadge.title = `Foreign Key → ${foreignKeyRef.table}.${foreignKeyRef.column}`;
|
|
1146
|
+
keyBadges.appendChild(fkBadge);
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
if (isUnique && !isPrimaryKey) {
|
|
1150
|
+
const uqBadge = document.createElement('span');
|
|
1151
|
+
uqBadge.className = 'key-badge key-badge-uq';
|
|
1152
|
+
uqBadge.textContent = 'UQ';
|
|
1153
|
+
uqBadge.title = 'Unique Constraint';
|
|
1154
|
+
keyBadges.appendChild(uqBadge);
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
if (keyBadges.children.length > 0) {
|
|
1158
|
+
columnNameRow.appendChild(keyBadges);
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
// Column datatype
|
|
1162
|
+
const columnDatatype = document.createElement('div');
|
|
1163
|
+
columnDatatype.className = 'column-datatype';
|
|
1164
|
+
columnDatatype.textContent = dataType;
|
|
1165
|
+
|
|
1166
|
+
columnHeader.appendChild(columnNameRow);
|
|
1167
|
+
columnHeader.appendChild(columnDatatype);
|
|
1168
|
+
th.appendChild(columnHeader);
|
|
1169
|
+
|
|
552
1170
|
if (tab.sortColumn === column) {
|
|
553
1171
|
th.classList.add(`sorted-${tab.sortDirection}`);
|
|
554
1172
|
}
|
|
@@ -582,9 +1200,10 @@ function renderTable(data) {
|
|
|
582
1200
|
table.appendChild(thead);
|
|
583
1201
|
|
|
584
1202
|
const tbody = document.createElement('tbody');
|
|
585
|
-
|
|
1203
|
+
// Server-side sorting, so rows are already sorted
|
|
1204
|
+
const rows = data.rows || [];
|
|
586
1205
|
|
|
587
|
-
|
|
1206
|
+
rows.forEach(row => {
|
|
588
1207
|
const tr = document.createElement('tr');
|
|
589
1208
|
visibleColumns.forEach(column => {
|
|
590
1209
|
const td = document.createElement('td');
|
|
@@ -597,11 +1216,34 @@ function renderTable(data) {
|
|
|
597
1216
|
}
|
|
598
1217
|
|
|
599
1218
|
const value = row[column];
|
|
1219
|
+
|
|
1220
|
+
// Store original value for popup
|
|
1221
|
+
td.dataset.originalValue = value !== null && value !== undefined
|
|
1222
|
+
? (isJsonValue(value) ? JSON.stringify(value, null, 2) : String(value))
|
|
1223
|
+
: 'NULL';
|
|
1224
|
+
td.dataset.columnName = column;
|
|
1225
|
+
|
|
1226
|
+
td.addEventListener('click', (e) => {
|
|
1227
|
+
e.stopPropagation();
|
|
1228
|
+
showCellContentPopup(column, value);
|
|
1229
|
+
});
|
|
1230
|
+
|
|
1231
|
+
td.style.cursor = 'pointer';
|
|
1232
|
+
|
|
600
1233
|
if (value === null || value === undefined) {
|
|
601
1234
|
const nullSpan = document.createElement('span');
|
|
602
1235
|
nullSpan.className = 'null-value';
|
|
603
1236
|
nullSpan.textContent = 'NULL';
|
|
604
1237
|
td.appendChild(nullSpan);
|
|
1238
|
+
} else if (isJsonValue(value)) {
|
|
1239
|
+
const jsonPre = document.createElement('pre');
|
|
1240
|
+
jsonPre.className = 'json-value';
|
|
1241
|
+
try {
|
|
1242
|
+
jsonPre.textContent = JSON.stringify(value, null, 2);
|
|
1243
|
+
} catch (e) {
|
|
1244
|
+
jsonPre.textContent = String(value);
|
|
1245
|
+
}
|
|
1246
|
+
td.appendChild(jsonPre);
|
|
605
1247
|
} else {
|
|
606
1248
|
td.textContent = String(value);
|
|
607
1249
|
}
|
|
@@ -636,6 +1278,7 @@ function setupColumnSelector(tab, columns, tableHeader) {
|
|
|
636
1278
|
const columnButton = tableHeader.querySelector('#columnButton');
|
|
637
1279
|
const columnMenu = tableHeader.querySelector('#columnMenu');
|
|
638
1280
|
const columnMenuOptions = tableHeader.querySelector('#columnMenuOptions');
|
|
1281
|
+
const columnMenuHeader = tableHeader.querySelector('.column-menu-header');
|
|
639
1282
|
|
|
640
1283
|
if (!columnButton || !columnMenu || !columnMenuOptions) {
|
|
641
1284
|
console.warn('Column selector elements not found');
|
|
@@ -644,6 +1287,55 @@ function setupColumnSelector(tab, columns, tableHeader) {
|
|
|
644
1287
|
|
|
645
1288
|
columnMenuOptions.innerHTML = '';
|
|
646
1289
|
|
|
1290
|
+
// Check if any columns are hidden
|
|
1291
|
+
const hasHiddenColumns = tab.hiddenColumns && tab.hiddenColumns.length > 0;
|
|
1292
|
+
|
|
1293
|
+
if (columnMenuHeader) {
|
|
1294
|
+
let headerTitle = columnMenuHeader.querySelector('.column-menu-header-title');
|
|
1295
|
+
if (!headerTitle) {
|
|
1296
|
+
const headerText = columnMenuHeader.textContent.trim();
|
|
1297
|
+
columnMenuHeader.innerHTML = '';
|
|
1298
|
+
headerTitle = document.createElement('span');
|
|
1299
|
+
headerTitle.className = 'column-menu-header-title';
|
|
1300
|
+
headerTitle.textContent = headerText || 'Columns';
|
|
1301
|
+
columnMenuHeader.appendChild(headerTitle);
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
let selectAllButton = columnMenuHeader.querySelector('.column-select-all-button');
|
|
1305
|
+
if (hasHiddenColumns) {
|
|
1306
|
+
if (!selectAllButton) {
|
|
1307
|
+
selectAllButton = document.createElement('button');
|
|
1308
|
+
selectAllButton.className = 'column-select-all-button';
|
|
1309
|
+
selectAllButton.textContent = 'Select All';
|
|
1310
|
+
selectAllButton.title = 'Show all columns';
|
|
1311
|
+
selectAllButton.addEventListener('click', (e) => {
|
|
1312
|
+
e.stopPropagation();
|
|
1313
|
+
// Show all columns
|
|
1314
|
+
tab.hiddenColumns = [];
|
|
1315
|
+
if (tab.data) {
|
|
1316
|
+
renderTable(tab.data);
|
|
1317
|
+
requestAnimationFrame(() => {
|
|
1318
|
+
const newTableHeader = document.querySelector('.table-header');
|
|
1319
|
+
if (newTableHeader) {
|
|
1320
|
+
const newColumnMenu = newTableHeader.querySelector('#columnMenu');
|
|
1321
|
+
if (newColumnMenu) {
|
|
1322
|
+
newColumnMenu.style.display = 'block';
|
|
1323
|
+
const columns = Object.keys(tab.data.rows[0] || {});
|
|
1324
|
+
setupColumnSelector(tab, columns, newTableHeader);
|
|
1325
|
+
updateColumnButtonLabel(tab, columns, newTableHeader);
|
|
1326
|
+
}
|
|
1327
|
+
}
|
|
1328
|
+
});
|
|
1329
|
+
}
|
|
1330
|
+
});
|
|
1331
|
+
columnMenuHeader.appendChild(selectAllButton);
|
|
1332
|
+
}
|
|
1333
|
+
selectAllButton.style.display = 'block';
|
|
1334
|
+
} else if (selectAllButton) {
|
|
1335
|
+
selectAllButton.style.display = 'none';
|
|
1336
|
+
}
|
|
1337
|
+
}
|
|
1338
|
+
|
|
647
1339
|
columns.forEach(column => {
|
|
648
1340
|
const label = document.createElement('label');
|
|
649
1341
|
label.className = 'column-option';
|
|
@@ -806,9 +1498,11 @@ function handleSort(column) {
|
|
|
806
1498
|
tab.sortDirection = 'asc';
|
|
807
1499
|
}
|
|
808
1500
|
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
1501
|
+
// Reload data from server with new sort
|
|
1502
|
+
tab.page = 1; // Reset to page 1 on sort change
|
|
1503
|
+
tab.cursor = null;
|
|
1504
|
+
tab.cursorHistory = [];
|
|
1505
|
+
loadTableData();
|
|
812
1506
|
}
|
|
813
1507
|
|
|
814
1508
|
/**
|
|
@@ -836,9 +1530,26 @@ function getSortedRows(rows, tab) {
|
|
|
836
1530
|
return tab.sortDirection === 'asc' ? aVal - bVal : bVal - aVal;
|
|
837
1531
|
}
|
|
838
1532
|
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
1533
|
+
let aStr, bStr;
|
|
1534
|
+
if (isJsonValue(aVal)) {
|
|
1535
|
+
try {
|
|
1536
|
+
aStr = JSON.stringify(aVal).toLowerCase();
|
|
1537
|
+
} catch (e) {
|
|
1538
|
+
aStr = String(aVal).toLowerCase();
|
|
1539
|
+
}
|
|
1540
|
+
} else {
|
|
1541
|
+
aStr = String(aVal).toLowerCase();
|
|
1542
|
+
}
|
|
1543
|
+
|
|
1544
|
+
if (isJsonValue(bVal)) {
|
|
1545
|
+
try {
|
|
1546
|
+
bStr = JSON.stringify(bVal).toLowerCase();
|
|
1547
|
+
} catch (e) {
|
|
1548
|
+
bStr = String(bVal).toLowerCase();
|
|
1549
|
+
}
|
|
1550
|
+
} else {
|
|
1551
|
+
bStr = String(bVal).toLowerCase();
|
|
1552
|
+
}
|
|
842
1553
|
|
|
843
1554
|
if (tab.sortDirection === 'asc') {
|
|
844
1555
|
return aStr < bStr ? -1 : aStr > bStr ? 1 : 0;
|
|
@@ -854,7 +1565,10 @@ function renderPagination() {
|
|
|
854
1565
|
const tab = getActiveTab();
|
|
855
1566
|
if (!tab) return;
|
|
856
1567
|
|
|
857
|
-
|
|
1568
|
+
if (!tab.limit) {
|
|
1569
|
+
tab.limit = 100; // Default limit for existing tabs
|
|
1570
|
+
}
|
|
1571
|
+
const limit = tab.limit;
|
|
858
1572
|
const totalPages = Math.ceil(tab.totalCount / limit);
|
|
859
1573
|
|
|
860
1574
|
if (totalPages <= 1) {
|
|
@@ -867,24 +1581,41 @@ function renderPagination() {
|
|
|
867
1581
|
const hasPrevious = tab.page > 1;
|
|
868
1582
|
const hasNext = tab.page < totalPages;
|
|
869
1583
|
|
|
1584
|
+
const startRow = ((tab.page - 1) * limit) + 1;
|
|
1585
|
+
const endRow = Math.min(tab.page * limit, tab.totalCount);
|
|
1586
|
+
|
|
870
1587
|
pagination.innerHTML = `
|
|
871
|
-
<
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
1588
|
+
<div class="pagination-content">
|
|
1589
|
+
<button
|
|
1590
|
+
class="pagination-button pagination-button-prev"
|
|
1591
|
+
${!hasPrevious ? 'disabled' : ''}
|
|
1592
|
+
onclick="handlePageChange(${tab.page - 1})"
|
|
1593
|
+
title="Previous page"
|
|
1594
|
+
>
|
|
1595
|
+
<svg class="pagination-icon" width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
1596
|
+
<path d="M10 12L6 8L10 4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
|
1597
|
+
</svg>
|
|
1598
|
+
<span>Previous</span>
|
|
1599
|
+
</button>
|
|
1600
|
+
<div class="pagination-info-container">
|
|
1601
|
+
<span class="pagination-info">
|
|
1602
|
+
<span class="pagination-page">Page <strong>${tab.page}</strong> of <strong>${totalPages}</strong></span>
|
|
1603
|
+
<span class="pagination-separator">•</span>
|
|
1604
|
+
<span class="pagination-rows">${startRow.toLocaleString()}–${endRow.toLocaleString()} of ${tab.totalCount.toLocaleString()}</span>
|
|
1605
|
+
</span>
|
|
1606
|
+
</div>
|
|
1607
|
+
<button
|
|
1608
|
+
class="pagination-button pagination-button-next"
|
|
1609
|
+
${!hasNext ? 'disabled' : ''}
|
|
1610
|
+
onclick="handlePageChange(${tab.page + 1})"
|
|
1611
|
+
title="Next page"
|
|
1612
|
+
>
|
|
1613
|
+
<span>Next</span>
|
|
1614
|
+
<svg class="pagination-icon" width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
1615
|
+
<path d="M6 12L10 8L6 4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
|
1616
|
+
</svg>
|
|
1617
|
+
</button>
|
|
1618
|
+
</div>
|
|
888
1619
|
`;
|
|
889
1620
|
}
|
|
890
1621
|
|
|
@@ -926,3 +1657,334 @@ function handlePageChange(newPage) {
|
|
|
926
1657
|
}
|
|
927
1658
|
|
|
928
1659
|
window.handlePageChange = handlePageChange;
|
|
1660
|
+
|
|
1661
|
+
/**
|
|
1662
|
+
* Check if a value is a date/time value and parse it.
|
|
1663
|
+
* Detects Date objects, ISO date strings, and PostgreSQL timestamp strings.
|
|
1664
|
+
* @param {*} value - Value to check
|
|
1665
|
+
* @returns {Date|null} Parsed Date object if valid date/time, null otherwise
|
|
1666
|
+
*/
|
|
1667
|
+
function isDateTimeValue(value) {
|
|
1668
|
+
if (value === null || value === undefined) {
|
|
1669
|
+
return null;
|
|
1670
|
+
}
|
|
1671
|
+
|
|
1672
|
+
if (value instanceof Date) {
|
|
1673
|
+
return isNaN(value.getTime()) ? null : value;
|
|
1674
|
+
}
|
|
1675
|
+
|
|
1676
|
+
if (typeof value === 'string') {
|
|
1677
|
+
const trimmed = value.trim();
|
|
1678
|
+
if (trimmed === '' || trimmed === 'NULL') {
|
|
1679
|
+
return null;
|
|
1680
|
+
}
|
|
1681
|
+
|
|
1682
|
+
// Try parsing as ISO date string or PostgreSQL timestamp
|
|
1683
|
+
// PostgreSQL timestamps: '2024-01-01 12:00:00' or '2024-01-01 12:00:00.123' or with timezone
|
|
1684
|
+
// ISO strings: '2024-01-01T12:00:00' or '2024-01-01T12:00:00Z' or with timezone offset
|
|
1685
|
+
const date = new Date(trimmed);
|
|
1686
|
+
if (!isNaN(date.getTime())) {
|
|
1687
|
+
const datePattern = /^\d{4}-\d{2}-\d{2}/;
|
|
1688
|
+
if (datePattern.test(trimmed)) {
|
|
1689
|
+
return date;
|
|
1690
|
+
}
|
|
1691
|
+
}
|
|
1692
|
+
}
|
|
1693
|
+
|
|
1694
|
+
return null;
|
|
1695
|
+
}
|
|
1696
|
+
|
|
1697
|
+
/**
|
|
1698
|
+
* Get user's current timezone.
|
|
1699
|
+
* @returns {string} IANA timezone identifier (e.g., 'America/New_York')
|
|
1700
|
+
*/
|
|
1701
|
+
function getCurrentTimezone() {
|
|
1702
|
+
try {
|
|
1703
|
+
return Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
1704
|
+
} catch (e) {
|
|
1705
|
+
return 'UTC';
|
|
1706
|
+
}
|
|
1707
|
+
}
|
|
1708
|
+
|
|
1709
|
+
/**
|
|
1710
|
+
* Get list of common timezones.
|
|
1711
|
+
* @returns {Array<{value: string, label: string}>} Array of timezone objects
|
|
1712
|
+
*/
|
|
1713
|
+
function getCommonTimezones() {
|
|
1714
|
+
const timezones = [
|
|
1715
|
+
{ value: 'UTC', label: 'UTC (Coordinated Universal Time)' },
|
|
1716
|
+
{ value: 'America/New_York', label: 'America/New_York (EST/EDT)' },
|
|
1717
|
+
{ value: 'America/Chicago', label: 'America/Chicago (CST/CDT)' },
|
|
1718
|
+
{ value: 'America/Denver', label: 'America/Denver (MST/MDT)' },
|
|
1719
|
+
{ value: 'America/Los_Angeles', label: 'America/Los_Angeles (PST/PDT)' },
|
|
1720
|
+
{ value: 'Europe/London', label: 'Europe/London (GMT/BST)' },
|
|
1721
|
+
{ value: 'Europe/Paris', label: 'Europe/Paris (CET/CEST)' },
|
|
1722
|
+
{ value: 'Europe/Berlin', label: 'Europe/Berlin (CET/CEST)' },
|
|
1723
|
+
{ value: 'Asia/Tokyo', label: 'Asia/Tokyo (JST)' },
|
|
1724
|
+
{ value: 'Asia/Shanghai', label: 'Asia/Shanghai (CST)' },
|
|
1725
|
+
{ value: 'Asia/Dubai', label: 'Asia/Dubai (GST)' },
|
|
1726
|
+
{ value: 'Asia/Kolkata', label: 'Asia/Kolkata (IST)' },
|
|
1727
|
+
{ value: 'Australia/Sydney', label: 'Australia/Sydney (AEDT/AEST)' },
|
|
1728
|
+
{ value: 'Pacific/Auckland', label: 'Pacific/Auckland (NZDT/NZST)' },
|
|
1729
|
+
];
|
|
1730
|
+
|
|
1731
|
+
// Add current timezone if not already in list
|
|
1732
|
+
const currentTz = getCurrentTimezone();
|
|
1733
|
+
const hasCurrent = timezones.some(tz => tz.value === currentTz);
|
|
1734
|
+
if (!hasCurrent && currentTz !== 'UTC') {
|
|
1735
|
+
timezones.unshift({ value: currentTz, label: `${currentTz} (Current)` });
|
|
1736
|
+
}
|
|
1737
|
+
|
|
1738
|
+
return timezones;
|
|
1739
|
+
}
|
|
1740
|
+
|
|
1741
|
+
/**
|
|
1742
|
+
* Format date/time in specified timezone.
|
|
1743
|
+
* @param {Date} date - Date object to format
|
|
1744
|
+
* @param {string} timezone - IANA timezone identifier
|
|
1745
|
+
* @returns {string} Formatted date/time string with timezone info
|
|
1746
|
+
*/
|
|
1747
|
+
function formatDateTimeInTimezone(date, timezone) {
|
|
1748
|
+
try {
|
|
1749
|
+
const formatter = new Intl.DateTimeFormat('en-US', {
|
|
1750
|
+
timeZone: timezone,
|
|
1751
|
+
year: 'numeric',
|
|
1752
|
+
month: '2-digit',
|
|
1753
|
+
day: '2-digit',
|
|
1754
|
+
hour: '2-digit',
|
|
1755
|
+
minute: '2-digit',
|
|
1756
|
+
second: '2-digit',
|
|
1757
|
+
fractionalSecondDigits: 3,
|
|
1758
|
+
hour12: false,
|
|
1759
|
+
});
|
|
1760
|
+
|
|
1761
|
+
const parts = formatter.formatToParts(date);
|
|
1762
|
+
const year = parts.find(p => p.type === 'year').value;
|
|
1763
|
+
const month = parts.find(p => p.type === 'month').value;
|
|
1764
|
+
const day = parts.find(p => p.type === 'day').value;
|
|
1765
|
+
const hour = parts.find(p => p.type === 'hour').value;
|
|
1766
|
+
const minute = parts.find(p => p.type === 'minute').value;
|
|
1767
|
+
const second = parts.find(p => p.type === 'second').value;
|
|
1768
|
+
const fractionalSecond = parts.find(p => p.type === 'fractionalSecond')?.value || '';
|
|
1769
|
+
|
|
1770
|
+
const tzFormatter = new Intl.DateTimeFormat('en-US', {
|
|
1771
|
+
timeZone: timezone,
|
|
1772
|
+
timeZoneName: 'short',
|
|
1773
|
+
});
|
|
1774
|
+
const tzParts = tzFormatter.formatToParts(date);
|
|
1775
|
+
const tzName = tzParts.find(p => p.type === 'timeZoneName')?.value || timezone;
|
|
1776
|
+
|
|
1777
|
+
const dateStr = `${year}-${month}-${day}`;
|
|
1778
|
+
const timeStr = `${hour}:${minute}:${second}${fractionalSecond ? '.' + fractionalSecond : ''}`;
|
|
1779
|
+
|
|
1780
|
+
return `${dateStr} ${timeStr} ${tzName}`;
|
|
1781
|
+
} catch (e) {
|
|
1782
|
+
return date.toISOString();
|
|
1783
|
+
}
|
|
1784
|
+
}
|
|
1785
|
+
|
|
1786
|
+
/**
|
|
1787
|
+
* Format cell content for display in popup dialog.
|
|
1788
|
+
* Handles JSON values, null values, JSON strings, date/time values, and regular text appropriately.
|
|
1789
|
+
* @param {*} value - The cell value to format
|
|
1790
|
+
* @returns {Object} Object with formatted content, isJson flag, isDateTime flag, and dateValue: { content: string, isJson: boolean, isDateTime: boolean, dateValue: Date | null }
|
|
1791
|
+
*/
|
|
1792
|
+
function formatCellContentForPopup(value, timezone = null) {
|
|
1793
|
+
if (value === null || value === undefined) {
|
|
1794
|
+
return { content: 'NULL', isJson: false, isDateTime: false, dateValue: null };
|
|
1795
|
+
}
|
|
1796
|
+
|
|
1797
|
+
const dateValue = isDateTimeValue(value);
|
|
1798
|
+
if (dateValue) {
|
|
1799
|
+
const tz = timezone || getCurrentTimezone();
|
|
1800
|
+
const formatted = formatDateTimeInTimezone(dateValue, tz);
|
|
1801
|
+
return { content: formatted, isJson: false, isDateTime: true, dateValue: dateValue };
|
|
1802
|
+
}
|
|
1803
|
+
|
|
1804
|
+
// Handle JSON objects/arrays
|
|
1805
|
+
if (isJsonValue(value)) {
|
|
1806
|
+
try {
|
|
1807
|
+
return { content: JSON.stringify(value, null, 2), isJson: true, isDateTime: false, dateValue: null };
|
|
1808
|
+
} catch (e) {
|
|
1809
|
+
return { content: String(value), isJson: false, isDateTime: false, dateValue: null };
|
|
1810
|
+
}
|
|
1811
|
+
}
|
|
1812
|
+
|
|
1813
|
+
// Handle string values - check if it's a JSON string
|
|
1814
|
+
if (typeof value === 'string') {
|
|
1815
|
+
const trimmed = value.trim();
|
|
1816
|
+
// Check if string looks like JSON (starts with { or [)
|
|
1817
|
+
if ((trimmed.startsWith('{') && trimmed.endsWith('}')) ||
|
|
1818
|
+
(trimmed.startsWith('[') && trimmed.endsWith(']'))) {
|
|
1819
|
+
try {
|
|
1820
|
+
const parsed = JSON.parse(trimmed);
|
|
1821
|
+
return { content: JSON.stringify(parsed, null, 2), isJson: true, isDateTime: false, dateValue: null };
|
|
1822
|
+
} catch (e) {
|
|
1823
|
+
return { content: String(value), isJson: false, isDateTime: false, dateValue: null };
|
|
1824
|
+
}
|
|
1825
|
+
}
|
|
1826
|
+
}
|
|
1827
|
+
|
|
1828
|
+
return { content: String(value), isJson: false, isDateTime: false, dateValue: null };
|
|
1829
|
+
}
|
|
1830
|
+
|
|
1831
|
+
/**
|
|
1832
|
+
* Show popup dialog with full cell content.
|
|
1833
|
+
* @param {string} column - Column name
|
|
1834
|
+
* @param {*} value - Cell value
|
|
1835
|
+
*/
|
|
1836
|
+
function showCellContentPopup(column, value) {
|
|
1837
|
+
closeCellContentPopup();
|
|
1838
|
+
|
|
1839
|
+
const overlay = document.createElement('div');
|
|
1840
|
+
overlay.className = 'cell-popup-overlay';
|
|
1841
|
+
overlay.addEventListener('click', (e) => {
|
|
1842
|
+
if (e.target === overlay) {
|
|
1843
|
+
closeCellContentPopup();
|
|
1844
|
+
}
|
|
1845
|
+
});
|
|
1846
|
+
|
|
1847
|
+
const dialog = document.createElement('div');
|
|
1848
|
+
dialog.className = 'cell-popup-dialog';
|
|
1849
|
+
dialog.addEventListener('click', (e) => {
|
|
1850
|
+
e.stopPropagation();
|
|
1851
|
+
});
|
|
1852
|
+
|
|
1853
|
+
const formatted = formatCellContentForPopup(value);
|
|
1854
|
+
const formattedContent = formatted.content;
|
|
1855
|
+
const isJson = formatted.isJson;
|
|
1856
|
+
const isDateTime = formatted.isDateTime;
|
|
1857
|
+
|
|
1858
|
+
const header = document.createElement('div');
|
|
1859
|
+
header.className = 'cell-popup-header';
|
|
1860
|
+
|
|
1861
|
+
const title = document.createElement('h3');
|
|
1862
|
+
title.className = 'cell-popup-title';
|
|
1863
|
+
title.textContent = column;
|
|
1864
|
+
header.appendChild(title);
|
|
1865
|
+
|
|
1866
|
+
const headerActions = document.createElement('div');
|
|
1867
|
+
headerActions.className = 'cell-popup-actions';
|
|
1868
|
+
|
|
1869
|
+
const copyButton = document.createElement('button');
|
|
1870
|
+
copyButton.className = 'cell-popup-copy';
|
|
1871
|
+
copyButton.innerHTML = '📋';
|
|
1872
|
+
copyButton.title = 'Copy to clipboard';
|
|
1873
|
+
|
|
1874
|
+
const updateContent = () => {
|
|
1875
|
+
let contentToDisplay = '';
|
|
1876
|
+
let contentToCopy = '';
|
|
1877
|
+
|
|
1878
|
+
if (value === null || value === undefined) {
|
|
1879
|
+
content.classList.add('null-content');
|
|
1880
|
+
content.classList.remove('json-value-popup', 'datetime-value-popup');
|
|
1881
|
+
contentToDisplay = 'NULL';
|
|
1882
|
+
contentToCopy = 'NULL';
|
|
1883
|
+
} else if (isJson) {
|
|
1884
|
+
const formatted = formatCellContentForPopup(value);
|
|
1885
|
+
content.classList.add('json-value-popup');
|
|
1886
|
+
content.classList.remove('null-content', 'datetime-value-popup');
|
|
1887
|
+
contentToDisplay = formatted.content;
|
|
1888
|
+
contentToCopy = formatted.content;
|
|
1889
|
+
} else if (isDateTime) {
|
|
1890
|
+
// Display original date/time value without timezone conversion
|
|
1891
|
+
content.classList.add('datetime-value-popup');
|
|
1892
|
+
content.classList.remove('null-content', 'json-value-popup');
|
|
1893
|
+
contentToDisplay = String(value);
|
|
1894
|
+
contentToCopy = String(value);
|
|
1895
|
+
} else {
|
|
1896
|
+
const formatted = formatCellContentForPopup(value);
|
|
1897
|
+
content.classList.remove('null-content', 'json-value-popup', 'datetime-value-popup');
|
|
1898
|
+
contentToDisplay = formatted.content;
|
|
1899
|
+
contentToCopy = formatted.content;
|
|
1900
|
+
}
|
|
1901
|
+
|
|
1902
|
+
content.textContent = contentToDisplay;
|
|
1903
|
+
copyButton._formattedContent = contentToCopy;
|
|
1904
|
+
};
|
|
1905
|
+
|
|
1906
|
+
copyButton.addEventListener('click', async () => {
|
|
1907
|
+
try {
|
|
1908
|
+
const textToCopy = copyButton._formattedContent || formattedContent;
|
|
1909
|
+
await navigator.clipboard.writeText(textToCopy);
|
|
1910
|
+
copyButton.innerHTML = '✓';
|
|
1911
|
+
copyButton.title = 'Copied!';
|
|
1912
|
+
copyButton.classList.add('copied');
|
|
1913
|
+
setTimeout(() => {
|
|
1914
|
+
copyButton.innerHTML = '📋';
|
|
1915
|
+
copyButton.title = 'Copy to clipboard';
|
|
1916
|
+
copyButton.classList.remove('copied');
|
|
1917
|
+
}, 2000);
|
|
1918
|
+
} catch (err) {
|
|
1919
|
+
console.error('Failed to copy:', err);
|
|
1920
|
+
copyButton.innerHTML = '✗';
|
|
1921
|
+
copyButton.title = 'Copy failed';
|
|
1922
|
+
setTimeout(() => {
|
|
1923
|
+
copyButton.innerHTML = '📋';
|
|
1924
|
+
copyButton.title = 'Copy to clipboard';
|
|
1925
|
+
}, 2000);
|
|
1926
|
+
}
|
|
1927
|
+
});
|
|
1928
|
+
copyButton._formattedContent = formattedContent;
|
|
1929
|
+
headerActions.appendChild(copyButton);
|
|
1930
|
+
|
|
1931
|
+
const closeButton = document.createElement('button');
|
|
1932
|
+
closeButton.className = 'cell-popup-close';
|
|
1933
|
+
closeButton.innerHTML = '×';
|
|
1934
|
+
closeButton.title = 'Close';
|
|
1935
|
+
closeButton.addEventListener('click', closeCellContentPopup);
|
|
1936
|
+
headerActions.appendChild(closeButton);
|
|
1937
|
+
|
|
1938
|
+
header.appendChild(headerActions);
|
|
1939
|
+
|
|
1940
|
+
const body = document.createElement('div');
|
|
1941
|
+
body.className = 'cell-popup-body';
|
|
1942
|
+
|
|
1943
|
+
const content = document.createElement('pre');
|
|
1944
|
+
content.className = 'cell-popup-content';
|
|
1945
|
+
|
|
1946
|
+
updateContent();
|
|
1947
|
+
|
|
1948
|
+
body.appendChild(content);
|
|
1949
|
+
|
|
1950
|
+
dialog.appendChild(header);
|
|
1951
|
+
dialog.appendChild(body);
|
|
1952
|
+
overlay.appendChild(dialog);
|
|
1953
|
+
|
|
1954
|
+
document.body.appendChild(overlay);
|
|
1955
|
+
|
|
1956
|
+
const escapeHandler = (e) => {
|
|
1957
|
+
if (e.key === 'Escape') {
|
|
1958
|
+
closeCellContentPopup();
|
|
1959
|
+
}
|
|
1960
|
+
};
|
|
1961
|
+
overlay.dataset.escapeHandler = 'true';
|
|
1962
|
+
document.addEventListener('keydown', escapeHandler);
|
|
1963
|
+
|
|
1964
|
+
overlay._escapeHandler = escapeHandler;
|
|
1965
|
+
}
|
|
1966
|
+
|
|
1967
|
+
/**
|
|
1968
|
+
* Close the cell content popup dialog.
|
|
1969
|
+
*/
|
|
1970
|
+
function closeCellContentPopup() {
|
|
1971
|
+
const overlay = document.querySelector('.cell-popup-overlay');
|
|
1972
|
+
if (overlay) {
|
|
1973
|
+
if (overlay._escapeHandler) {
|
|
1974
|
+
document.removeEventListener('keydown', overlay._escapeHandler);
|
|
1975
|
+
}
|
|
1976
|
+
overlay.remove();
|
|
1977
|
+
}
|
|
1978
|
+
}
|
|
1979
|
+
|
|
1980
|
+
function showLoading() {
|
|
1981
|
+
if (loadingOverlay) {
|
|
1982
|
+
loadingOverlay.style.display = 'flex';
|
|
1983
|
+
}
|
|
1984
|
+
}
|
|
1985
|
+
|
|
1986
|
+
function hideLoading() {
|
|
1987
|
+
if (loadingOverlay) {
|
|
1988
|
+
loadingOverlay.style.display = 'none';
|
|
1989
|
+
}
|
|
1990
|
+
}
|