dbviewer 0.6.2 → 0.6.3

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.
@@ -131,8 +131,8 @@
131
131
 
132
132
  /* Action column styling */
133
133
  .action-column {
134
- width: 60px;
135
- min-width: 60px; /* Ensure minimum width */
134
+ width: 100px; /* Increased from 60px to accommodate two buttons */
135
+ min-width: 100px; /* Ensure minimum width */
136
136
  white-space: nowrap;
137
137
  position: sticky;
138
138
  left: 0;
@@ -141,6 +141,16 @@
141
141
  box-shadow: 2px 0 4px rgba(0, 0, 0, 0.05);
142
142
  }
143
143
 
144
+ .copy-factory-btn {
145
+ padding: 0.1rem 0.4rem;
146
+ width: 32px;
147
+ }
148
+
149
+ .copy-factory-btn:hover {
150
+ opacity: 0.85;
151
+ transform: translateY(-1px);
152
+ }
153
+
144
154
  /* Ensure proper background color for actions column in dark mode */
145
155
  [data-bs-theme="dark"] .action-column {
146
156
  background-color: var(--bs-dark-bg-subtle, #343a40);
@@ -522,8 +532,8 @@
522
532
 
523
533
  /* Action column styling */
524
534
  .action-column {
525
- width: 60px;
526
- min-width: 60px; /* Ensure minimum width */
535
+ width: 100px; /* Increased from 60px to accommodate two buttons */
536
+ min-width: 100px; /* Ensure minimum width */
527
537
  white-space: nowrap;
528
538
  position: sticky;
529
539
  left: 0;
@@ -533,6 +543,16 @@
533
543
  border-right: 1px solid var(--bs-border-color);
534
544
  }
535
545
 
546
+ .copy-factory-btn {
547
+ padding: 0.1rem 0.4rem;
548
+ width: 32px;
549
+ }
550
+
551
+ .copy-factory-btn:hover {
552
+ opacity: 0.85;
553
+ transform: translateY(-1px);
554
+ }
555
+
536
556
  /* Ensure proper background color for actions column in dark mode */
537
557
  [data-bs-theme="dark"] .action-column {
538
558
  background-color: var(--bs-body-bg, #212529); /* Use body background in dark mode */
@@ -1493,154 +1513,220 @@
1493
1513
  }
1494
1514
  });
1495
1515
  }
1496
- });
1497
-
1498
- // Helper function to create relationship sections
1499
- // Function to fetch relationship counts from API
1500
- async function fetchRelationshipCounts(tableName, recordId, relationships, hasManySection) {
1501
- try {
1502
- const response = await fetch(`/dbviewer/api/tables/${tableName}/relationship_counts?record_id=${recordId}`);
1503
- if (!response.ok) {
1504
- throw new Error(`HTTP error! status: ${response.status}`);
1505
- }
1506
-
1507
- const data = await response.json();
1508
-
1509
- // Update each count in the UI
1510
- const countSpans = hasManySection.querySelectorAll('.relationship-count');
1511
-
1512
- relationships.forEach((relationship, index) => {
1513
- const countSpan = countSpans[index];
1514
- if (countSpan) {
1515
- const relationshipData = data.relationships.find(r =>
1516
- r.table === relationship.from_table && r.foreign_key === relationship.column
1517
- );
1516
+
1517
+ // Function to copy FactoryBot code
1518
+ window.copyToJson = function(button) {
1519
+ try {
1520
+ // Get record data from data attribute
1521
+ const recordData = JSON.parse(button.dataset.recordData);
1522
+
1523
+ // Generate formatted JSON string
1524
+ const jsonString = JSON.stringify(recordData, null, 2);
1525
+
1526
+ // Copy to clipboard
1527
+ navigator.clipboard.writeText(jsonString).then(() => {
1528
+ // Show a temporary success message on the button
1529
+ const originalTitle = button.getAttribute('title');
1530
+ button.setAttribute('title', 'Copied!');
1531
+ button.classList.remove('btn-outline-secondary');
1532
+ button.classList.add('btn-success');
1518
1533
 
1519
- if (relationshipData) {
1520
- const count = relationshipData.count;
1521
- let badgeClass = 'bg-secondary';
1522
- let badgeText = `${count} record${count !== 1 ? 's' : ''}`;
1523
-
1524
- // Use different colors based on count
1525
- if (count > 0) {
1526
- badgeClass = count > 10 ? 'bg-warning' : 'bg-success';
1527
- }
1528
-
1529
- countSpan.innerHTML = `<span class="badge ${badgeClass}">${badgeText}</span>`;
1534
+ // Show a toast notification
1535
+ if (typeof Toastify === 'function') {
1536
+ Toastify({
1537
+ text: `<span class="toast-icon"><i class="bi bi-clipboard-check"></i></span> JSON data copied to clipboard!`,
1538
+ className: "toast-factory-bot",
1539
+ duration: 3000,
1540
+ gravity: "bottom",
1541
+ position: "right",
1542
+ escapeMarkup: false,
1543
+ style: {
1544
+ animation: "slideInRight 0.3s ease-out, slideOutRight 0.3s ease-out 2.7s"
1545
+ },
1546
+ onClick: function() { /* Dismiss toast on click */ }
1547
+ }).showToast();
1548
+ }
1549
+
1550
+ setTimeout(() => {
1551
+ button.setAttribute('title', originalTitle);
1552
+ button.classList.remove('btn-success');
1553
+ button.classList.add('btn-outline-secondary');
1554
+ }, 2000);
1555
+ }).catch(err => {
1556
+ console.error('Failed to copy text: ', err);
1557
+
1558
+ // Show error toast
1559
+ if (typeof Toastify === 'function') {
1560
+ Toastify({
1561
+ text: '<span class="toast-icon"><i class="bi bi-exclamation-triangle"></i></span> Failed to copy to clipboard',
1562
+ className: "bg-danger",
1563
+ duration: 3000,
1564
+ gravity: "bottom",
1565
+ position: "right",
1566
+ escapeMarkup: false,
1567
+ style: {
1568
+ background: "linear-gradient(135deg, #dc3545, #c82333)",
1569
+ animation: "slideInRight 0.3s ease-out"
1570
+ }
1571
+ }).showToast();
1530
1572
  } else {
1531
- // Fallback if no data found
1532
- countSpan.innerHTML = '<span class="badge bg-danger">Error</span>';
1573
+ alert('Failed to copy to clipboard. See console for details.');
1533
1574
  }
1575
+ });
1576
+ } catch (error) {
1577
+ console.error('Error generating JSON:', error);
1578
+ alert('Error generating JSON. See console for details.');
1579
+ }
1580
+ };
1581
+
1582
+ // Helper function to create relationship sections
1583
+ // Function to fetch relationship counts from API
1584
+ async function fetchRelationshipCounts(tableName, recordId, relationships, hasManySection) {
1585
+ try {
1586
+ const response = await fetch(`/dbviewer/api/tables/${tableName}/relationship_counts?record_id=${recordId}`);
1587
+ if (!response.ok) {
1588
+ throw new Error(`HTTP error! status: ${response.status}`);
1534
1589
  }
1535
- });
1536
-
1537
- } catch (error) {
1538
- console.error('Error fetching relationship counts:', error);
1539
-
1540
- // Show error state in UI
1541
- const countSpans = hasManySection.querySelectorAll('.relationship-count');
1542
- countSpans.forEach(span => {
1543
- span.innerHTML = '<span class="badge bg-danger">Error</span>';
1544
- });
1545
- }
1546
- }
1590
+
1591
+ const data = await response.json();
1592
+
1593
+ // Update each count in the UI
1594
+ const countSpans = hasManySection.querySelectorAll('.relationship-count');
1595
+
1596
+ relationships.forEach((relationship, index) => {
1597
+ const countSpan = countSpans[index];
1598
+ if (countSpan) {
1599
+ const relationshipData = data.relationships.find(r =>
1600
+ r.table === relationship.from_table && r.foreign_key === relationship.column
1601
+ );
1602
+
1603
+ if (relationshipData) {
1604
+ const count = relationshipData.count;
1605
+ let badgeClass = 'bg-secondary';
1606
+ let badgeText = `${count} record${count !== 1 ? 's' : ''}`;
1607
+
1608
+ // Use different colors based on count
1609
+ if (count > 0) {
1610
+ badgeClass = count > 10 ? 'bg-warning' : 'bg-success';
1611
+ }
1612
+
1613
+ countSpan.innerHTML = `<span class="badge ${badgeClass}">${badgeText}</span>`;
1614
+ } else {
1615
+ // Fallback if no data found
1616
+ countSpan.innerHTML = '<span class="badge bg-danger">Error</span>';
1547
1617
 
1548
- function createRelationshipSection(title, relationships, recordData, type, primaryKeyValue = null) {
1549
- const section = document.createElement('div');
1550
- section.className = 'relationship-section mb-4';
1551
-
1552
- // Create section header
1553
- const header = document.createElement('h6');
1554
- header.className = 'mb-3';
1555
- const icon = type === 'belongs_to' ? 'bi-arrow-up-right' : 'bi-arrow-down-left';
1556
- header.innerHTML = `<i class="bi ${icon} me-2"></i>${title}`;
1557
- section.appendChild(header);
1558
-
1559
- const tableContainer = document.createElement('div');
1560
- tableContainer.className = 'table-responsive';
1561
-
1562
- const table = document.createElement('table');
1563
- table.className = 'table table-sm table-bordered';
1564
-
1565
- // Create header based on relationship type
1566
- const thead = document.createElement('thead');
1567
- if (type === 'belongs_to') {
1568
- thead.innerHTML = `
1569
- <tr>
1570
- <th width="25%">Column</th>
1571
- <th width="25%">Value</th>
1572
- <th width="25%">References</th>
1573
- <th width="25%">Action</th>
1574
- </tr>
1575
- `;
1576
- } else {
1577
- thead.innerHTML = `
1578
- <tr>
1579
- <th width="30%">Related Table</th>
1580
- <th width="25%">Foreign Key</th>
1581
- <th width="20%">Count</th>
1582
- <th width="25%">Action</th>
1583
- </tr>
1584
- `;
1618
+ }
1619
+ }
1620
+ });
1621
+
1622
+ } catch (error) {
1623
+ console.error('Error fetching relationship counts:', error);
1624
+
1625
+ // Show error state in UI
1626
+ const countSpans = hasManySection.querySelectorAll('.relationship-count');
1627
+ countSpans.forEach(span => {
1628
+ span.innerHTML = '<span class="badge bg-danger">Error</span>';
1629
+ });
1630
+ }
1585
1631
  }
1586
- table.appendChild(thead);
1587
-
1588
- // Create body
1589
- const tbody = document.createElement('tbody');
1590
-
1591
- relationships.forEach(fk => {
1592
- const row = document.createElement('tr');
1632
+
1633
+ function createRelationshipSection(title, relationships, recordData, type, primaryKeyValue = null) {
1634
+ const section = document.createElement('div');
1635
+ section.className = 'relationship-section mb-4';
1636
+
1637
+ // Create section header
1638
+ const header = document.createElement('h6');
1639
+ header.className = 'mb-3';
1640
+ const icon = type === 'belongs_to' ? 'bi-arrow-up-right' : 'bi-arrow-down-left';
1641
+ header.innerHTML = `<i class="bi ${icon} me-2"></i>${title}`;
1642
+ section.appendChild(header);
1643
+
1644
+ const tableContainer = document.createElement('div');
1645
+ tableContainer.className = 'table-responsive';
1646
+
1647
+ const table = document.createElement('table');
1648
+ table.className = 'table table-sm table-bordered';
1593
1649
 
1650
+ // Create header based on relationship type
1651
+ const thead = document.createElement('thead');
1594
1652
  if (type === 'belongs_to') {
1595
- const columnValue = recordData[fk.column];
1596
- row.innerHTML = `
1597
- <td class="fw-medium">${fk.column}</td>
1598
- <td><code>${columnValue}</code></td>
1599
- <td>
1600
- <span class="text-muted">${fk.to_table}.</span><strong>${fk.primary_key}</strong>
1601
- </td>
1602
- <td>
1603
- <a href="/dbviewer/tables/${fk.to_table}?column_filters[${fk.primary_key}]=${encodeURIComponent(columnValue)}"
1604
- class="btn btn-sm btn-outline-primary"
1605
- title="View referenced record in ${fk.to_table}">
1606
- <i class="bi bi-arrow-right me-1"></i>View
1607
- </a>
1608
- </td>
1653
+ thead.innerHTML = `
1654
+ <tr>
1655
+ <th width="25%">Column</th>
1656
+ <th width="25%">Value</th>
1657
+ <th width="25%">References</th>
1658
+ <th width="25%">Action</th>
1659
+ </tr>
1609
1660
  `;
1610
1661
  } else {
1611
- // For has_many relationships
1612
- row.innerHTML = `
1613
- <td class="fw-medium">${fk.from_table}</td>
1614
- <td>
1615
- <span class="text-muted">${fk.from_table}.</span><strong>${fk.column}</strong>
1616
- </td>
1617
- <td>
1618
- <span class="relationship-count">
1619
- <span class="badge bg-secondary">
1620
- <span class="spinner-border spinner-border-sm me-1" role="status" aria-hidden="true"></span>
1621
- Loading...
1622
- </span>
1623
- </span>
1624
- </td>
1625
- <td>
1626
- <a href="/dbviewer/tables/${fk.from_table}?column_filters[${fk.column}]=${encodeURIComponent(primaryKeyValue)}"
1627
- class="btn btn-sm btn-outline-success"
1628
- title="View all ${fk.from_table} records that reference this record">
1629
- <i class="bi bi-list me-1"></i>View Related
1630
- </a>
1631
- </td>
1662
+ thead.innerHTML = `
1663
+ <tr>
1664
+ <th width="30%">Related Table</th>
1665
+ <th width="25%">Foreign Key</th>
1666
+ <th width="20%">Count</th>
1667
+ <th width="25%">Action</th>
1668
+ </tr>
1632
1669
  `;
1633
1670
  }
1671
+ table.appendChild(thead);
1634
1672
 
1635
- tbody.appendChild(row);
1636
- });
1637
-
1638
- table.appendChild(tbody);
1639
- tableContainer.appendChild(table);
1640
- section.appendChild(tableContainer);
1641
-
1642
- return section;
1643
- }
1673
+ // Create body
1674
+ const tbody = document.createElement('tbody');
1675
+
1676
+ relationships.forEach(fk => {
1677
+ const row = document.createElement('tr');
1678
+
1679
+ if (type === 'belongs_to') {
1680
+ const columnValue = recordData[fk.column];
1681
+ row.innerHTML = `
1682
+ <td class="fw-medium">${fk.column}</td>
1683
+ <td><code>${columnValue}</code></td>
1684
+ <td>
1685
+ <span class="text-muted">${fk.to_table}.</span><strong>${fk.primary_key}</strong>
1686
+ </td>
1687
+ <td>
1688
+ <a href="/dbviewer/tables/${fk.to_table}?column_filters[${fk.primary_key}]=${encodeURIComponent(columnValue)}"
1689
+ class="btn btn-sm btn-outline-primary"
1690
+ title="View referenced record in ${fk.to_table}">
1691
+ <i class="bi bi-arrow-right me-1"></i>View
1692
+ </a>
1693
+ </td>
1694
+ `;
1695
+ } else {
1696
+ // For has_many relationships
1697
+ row.innerHTML = `
1698
+ <td class="fw-medium">${fk.from_table}</td>
1699
+ <td>
1700
+ <span class="text-muted">${fk.from_table}.</span><strong>${fk.column}</strong>
1701
+ </td>
1702
+ <td>
1703
+ <span class="relationship-count">
1704
+ <span class="badge bg-secondary">
1705
+ <span class="spinner-border spinner-border-sm me-1" role="status" aria-hidden="true"></span>
1706
+ Loading...
1707
+ </span>
1708
+ </span>
1709
+ </td>
1710
+ <td>
1711
+ <a href="/dbviewer/tables/${fk.from_table}?column_filters[${fk.column}]=${encodeURIComponent(primaryKeyValue)}"
1712
+ class="btn btn-sm btn-outline-success"
1713
+ title="View all ${fk.from_table} records that reference this record">
1714
+ <i class="bi bi-list me-1"></i>View Related
1715
+ </a>
1716
+ </td>
1717
+ `;
1718
+ }
1719
+
1720
+ tbody.appendChild(row);
1721
+ });
1722
+
1723
+ table.appendChild(tbody);
1724
+ tableContainer.appendChild(table);
1725
+ section.appendChild(tableContainer);
1726
+
1727
+ return section;
1728
+ }
1729
+ });
1644
1730
  </script>
1645
1731
 
1646
1732
  <!-- Floating Creation Filter - Only visible on desktop and on table details page -->
@@ -29,6 +29,10 @@
29
29
  <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap">
30
30
  <!-- Chart.js for Data Visualization -->
31
31
  <script src="https://cdn.jsdelivr.net/npm/chart.js@3.9.1/dist/chart.min.js"></script>
32
+
33
+ <!-- Toastify JS for notifications -->
34
+ <link rel="stylesheet" type="text/css" href="https://cdn.jsdelivr.net/npm/toastify-js/src/toastify.min.css">
35
+ <script type="text/javascript" src="https://cdn.jsdelivr.net/npm/toastify-js"></script>
32
36
 
33
37
  <style>
34
38
  /* Base styles and typography */
@@ -1330,6 +1334,57 @@
1330
1334
  }
1331
1335
 
1332
1336
  /* Add this right above the style closing tag */
1337
+
1338
+ /* Toast styling customizations */
1339
+ .toastify {
1340
+ padding: 12px 20px;
1341
+ color: white;
1342
+ border-radius: 6px;
1343
+ display: flex;
1344
+ align-items: center;
1345
+ justify-content: space-between;
1346
+ font-family: var(--bs-body-font-family);
1347
+ font-size: 0.95rem;
1348
+ box-shadow: 0 6px 16px rgba(0, 0, 0, 0.15);
1349
+ max-width: 350px;
1350
+ }
1351
+
1352
+ .toast-factory-bot {
1353
+ background: linear-gradient(135deg, #28a745, #20c997);
1354
+ }
1355
+
1356
+ .toast-icon {
1357
+ margin-right: 10px;
1358
+ font-size: 1.25em;
1359
+ }
1360
+
1361
+ /* Dark mode toast styling */
1362
+ [data-bs-theme="dark"] .toast-factory-bot {
1363
+ background: linear-gradient(135deg, #157347, #13795b);
1364
+ }
1365
+
1366
+ /* Toast animations */
1367
+ @keyframes slideInRight {
1368
+ from {
1369
+ transform: translateX(100%);
1370
+ opacity: 0;
1371
+ }
1372
+ to {
1373
+ transform: translateX(0);
1374
+ opacity: 1;
1375
+ }
1376
+ }
1377
+
1378
+ @keyframes slideOutRight {
1379
+ from {
1380
+ transform: translateX(0);
1381
+ opacity: 1;
1382
+ }
1383
+ to {
1384
+ transform: translateX(100%);
1385
+ opacity: 0;
1386
+ }
1387
+ }
1333
1388
  </style>
1334
1389
  </head>
1335
1390
  <body>
@@ -28,7 +28,46 @@ module Dbviewer
28
28
  # @param table_name [String] Name of the table
29
29
  # @return [Class] ActiveRecord model class for the table
30
30
  def create_model_for(table_name)
31
- model = Dbviewer.const_set(table_name.classify, Class.new(ActiveRecord::Base) do
31
+ class_name = table_name.classify
32
+
33
+ # Check if we can reuse an existing constant
34
+ existing_model = handle_existing_constant(class_name, table_name)
35
+ return existing_model if existing_model
36
+
37
+ model = create_active_record_model(class_name, table_name)
38
+ model.establish_connection(@connection.instance_variable_get(:@config))
39
+ model
40
+ end
41
+
42
+ # Handle existing constant - check if we can reuse it or need to remove it
43
+ # @param class_name [String] The constant name to check
44
+ # @param table_name [String] The table name this model should represent
45
+ # @return [Class, nil] Existing model if reusable, nil otherwise
46
+ def handle_existing_constant(class_name, table_name)
47
+ return nil unless Dbviewer.const_defined?(class_name, false)
48
+
49
+ existing_model = Dbviewer.const_get(class_name)
50
+ return existing_model if valid_model_for_table?(existing_model, table_name)
51
+
52
+ # If it exists but isn't the right model, remove it first
53
+ Dbviewer.send(:remove_const, class_name)
54
+ nil
55
+ end
56
+
57
+ # Check if an existing model is valid for the given table
58
+ # @param model [Class] The model class to validate
59
+ # @param table_name [String] The expected table name
60
+ # @return [Boolean] true if the model is valid for the table, false otherwise
61
+ def valid_model_for_table?(model, table_name)
62
+ model.respond_to?(:table_name) && model.table_name == table_name
63
+ end
64
+
65
+ # Create a new ActiveRecord model class for a table
66
+ # @param class_name [String] The constant name for the model
67
+ # @param table_name [String] The table name this model should represent
68
+ # @return [Class] New ActiveRecord model class
69
+ def create_active_record_model(class_name, table_name)
70
+ Dbviewer.const_set(class_name, Class.new(ActiveRecord::Base) do
32
71
  self.table_name = table_name
33
72
 
34
73
  # Some tables might not have primary keys, so we handle that
@@ -45,10 +84,6 @@ module Dbviewer
45
84
  # Disable timestamps for better compatibility
46
85
  self.record_timestamps = false
47
86
  end)
48
-
49
- model.establish_connection(@connection.instance_variable_get(:@config))
50
-
51
- model
52
87
  end
53
88
  end
54
89
  end