dbviewer 0.4.1 → 0.4.2

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.
@@ -71,6 +71,28 @@
71
71
  background-color: var(--bs-dark-bg-subtle, #343a40) !important;
72
72
  }
73
73
 
74
+ /* Ensure proper layering for sticky elements */
75
+ .dbviewer-table-header th {
76
+ position: sticky;
77
+ top: 0;
78
+ z-index: 20;
79
+ }
80
+
81
+ /* Increase z-index for the intersection point of sticky header and sticky column */
82
+ .dbviewer-table-header th.action-column {
83
+ z-index: 40 !important;
84
+ box-shadow: 2px 2px 4px rgba(0, 0, 0, 0.1);
85
+ }
86
+
87
+ /* Ensure thead has higher z-index than tbody */
88
+ thead tr th.action-column {
89
+ z-index: 40 !important;
90
+ }
91
+
92
+ tbody tr td.action-column {
93
+ z-index: 30 !important;
94
+ }
95
+
74
96
  /* Improve mobile display for sort headers */
75
97
  @media (max-width: 767.98px) {
76
98
  .sortable-column .column-sort-link {
@@ -91,6 +113,176 @@
91
113
  [data-bs-theme="dark"] .sortable-column.sorted {
92
114
  background-color: rgba(255, 255, 255, 0.1);
93
115
  }
116
+
117
+ /* Column filter styling */
118
+ .column-filters td {
119
+ padding: 0.5rem;
120
+ background-color: var(--bs-tertiary-bg, #f8f9fa);
121
+ }
122
+
123
+ /* Action column styling */
124
+ .action-column {
125
+ width: 60px;
126
+ min-width: 60px; /* Ensure minimum width */
127
+ white-space: nowrap;
128
+ position: sticky;
129
+ left: 0;
130
+ z-index: 15;
131
+ background-color: var(--bs-table-striped-bg, #f2f2f2);
132
+ box-shadow: 2px 0 4px rgba(0, 0, 0, 0.05);
133
+ }
134
+
135
+ /* Ensure proper background color for actions column in dark mode */
136
+ [data-bs-theme="dark"] .action-column {
137
+ background-color: var(--bs-dark-bg-subtle, #343a40);
138
+ }
139
+
140
+ /* Maintain zebra striping with sticky action column */
141
+ .table-striped > tbody > tr:nth-of-type(odd) > .action-column {
142
+ background-color: var(--bs-table-striped-bg, #f8f9fa);
143
+ }
144
+
145
+ [data-bs-theme="dark"] .table-striped > tbody > tr:nth-of-type(odd) > .action-column {
146
+ background-color: var(--bs-dark-bg-subtle, #343a40);
147
+ }
148
+
149
+ .view-record-btn {
150
+ padding: 0.1rem 0.4rem;
151
+ width: 32px;
152
+ }
153
+
154
+ .view-record-btn:hover {
155
+ opacity: 0.85;
156
+ transform: translateY(-1px);
157
+ }
158
+
159
+ /* Make action column header sticky as well */
160
+ .action-column-header {
161
+ position: sticky;
162
+ left: 0;
163
+ z-index: 40 !important; /* Even higher z-index to stay on top of everything */
164
+ background-color: var(--bs-tertiary-bg, #f8f9fa) !important;
165
+ border-right: 1px solid var(--bs-border-color) !important; /* Added border for visual separation */
166
+ }
167
+
168
+ [data-bs-theme="dark"] .action-column-header {
169
+ background-color: var(--bs-dark-bg-subtle, #343a40) !important;
170
+ }
171
+
172
+ [data-bs-theme="dark"] .action-column-header {
173
+ background-color: var(--bs-dark-bg-subtle, #343a40) !important;
174
+ }
175
+
176
+ /* Make action column filter cell sticky as well */
177
+ .action-column-filter {
178
+ position: sticky;
179
+ left: 0;
180
+ z-index: 40 !important;
181
+ background-color: var(--bs-tertiary-bg, #f8f9fa) !important;
182
+ }
183
+
184
+ [data-bs-theme="dark"] .action-column-filter {
185
+ background-color: var(--bs-tertiary-bg, #2b3035) !important;
186
+ }
187
+
188
+ /* Fix action column for entire table */
189
+ .action-column {
190
+ border-right: 1px solid var(--bs-border-color);
191
+ }
192
+
193
+ /* Ensure equal padding for all cells */
194
+ .action-column-header, .action-column-filter {
195
+ padding-left: 8px !important;
196
+ padding-right: 8px !important;
197
+ }
198
+
199
+ /* Relationship section styles */
200
+ #relationshipsSection {
201
+ border-top: 1px solid var(--bs-border-color, #dee2e6);
202
+ margin-top: 1.5rem;
203
+ padding-top: 1.5rem;
204
+ }
205
+
206
+ #relationshipsSection h6 {
207
+ color: var(--bs-primary, #0d6efd);
208
+ font-weight: 600;
209
+ border-bottom: 2px solid var(--bs-primary, #0d6efd);
210
+ padding-bottom: 0.5rem;
211
+ margin-bottom: 1rem;
212
+ }
213
+
214
+ .relationship-section h6 {
215
+ font-size: 0.95rem;
216
+ margin-bottom: 0.75rem;
217
+ padding: 0.5rem 0.75rem;
218
+ background: linear-gradient(135deg, var(--bs-primary-bg-subtle, #cfe2ff), transparent);
219
+ border-left: 3px solid var(--bs-primary, #0d6efd);
220
+ border-radius: 0.25rem;
221
+ }
222
+
223
+ .relationship-section .table {
224
+ margin-bottom: 0;
225
+ border: 1px solid var(--bs-border-color, #dee2e6);
226
+ }
227
+
228
+ .relationship-section .table th {
229
+ background-color: var(--bs-light, #f8f9fa);
230
+ font-weight: 600;
231
+ font-size: 0.875rem;
232
+ border-bottom: 2px solid var(--bs-border-color, #dee2e6);
233
+ }
234
+
235
+ .relationship-section .table td {
236
+ vertical-align: middle;
237
+ font-size: 0.875rem;
238
+ }
239
+
240
+ .relationship-section .btn {
241
+ font-size: 0.8rem;
242
+ padding: 0.375rem 0.75rem;
243
+ }
244
+
245
+ .relationship-section .btn-outline-primary:hover {
246
+ transform: translateX(2px);
247
+ transition: transform 0.2s ease;
248
+ }
249
+
250
+ .relationship-section .btn-outline-success:hover {
251
+ transform: translateX(2px);
252
+ transition: transform 0.2s ease;
253
+ }
254
+
255
+ /* Dark mode relationship styles */
256
+ [data-bs-theme="dark"] #relationshipsSection {
257
+ border-top-color: var(--bs-border-color, #495057);
258
+ }
259
+
260
+ [data-bs-theme="dark"] .relationship-section h6 {
261
+ background: linear-gradient(135deg, var(--bs-primary-bg-subtle, #031633), transparent);
262
+ }
263
+
264
+ [data-bs-theme="dark"] .relationship-section .table th {
265
+ background-color: var(--bs-dark-bg-subtle, #343a40);
266
+ color: var(--bs-light, #f8f9fa);
267
+ }
268
+
269
+ [data-bs-theme="dark"] .relationship-section .table {
270
+ border-color: var(--bs-border-color, #495057);
271
+ }
272
+
273
+ /* Responsive relationship tables */
274
+ @media (max-width: 767.98px) {
275
+ .relationship-section .table th,
276
+ .relationship-section .table td {
277
+ font-size: 0.8rem;
278
+ padding: 0.5rem 0.25rem;
279
+ }
280
+
281
+ .relationship-section .btn {
282
+ font-size: 0.75rem;
283
+ padding: 0.25rem 0.5rem;
284
+ }
285
+ }
94
286
  </style>
95
287
 
96
288
  <script>
@@ -121,9 +313,6 @@
121
313
 
122
314
  <% content_for :sidebar_active do %>active<% end %>
123
315
 
124
- <% content_for :sidebar do %>
125
- <%= render 'dbviewer/shared/sidebar' %>
126
- <% end %>
127
316
  <div class="d-flex justify-content-between align-items-center mb-4">
128
317
  <div>
129
318
  <h1>Table: <%= @table_name %></h1>
@@ -207,20 +396,7 @@
207
396
  <div class="dbviewer-card card mb-4">
208
397
  <div class="card-header d-flex justify-content-between align-items-center">
209
398
  <h5 class="mb-0">
210
- <%
211
- # Build the URL query parameters including creation filters
212
- url_params = "per_page=' + this.value + '&page=1&order_by=#{@order_by}&order_direction=#{@order_direction}"
213
-
214
- # Add column filters
215
- if @column_filters.reject { |_, v| v.blank? }.any?
216
- url_params += "&" + @column_filters.reject { |_, v| v.blank? }.map { |k, v| "column_filters[#{k}]=#{CGI.escape(v.to_s)}" }.join("&")
217
- end
218
-
219
- # Add creation filters
220
- url_params += "&creation_filter_start=#{@creation_filter_start}" if @creation_filter_start.present?
221
- url_params += "&creation_filter_end=#{@creation_filter_end}" if @creation_filter_end.present?
222
- %>
223
- <select id="per-page-select" class="form-select form-select-sm" onchange="window.location.href='<%= table_path(@table_name) %>?<%= url_params %>'">
399
+ <select id="per-page-select" class="form-select form-select-sm" onchange="window.location.href='<%= table_path(@table_name) %>?<%= per_page_url_params(@table_name) %>'">
224
400
  <% Dbviewer::TablesController.per_page_options.each do |option| %>
225
401
  <option value="<%= option %>" <%= 'selected' if @per_page == option %>><%= option %></option>
226
402
  <% end %>
@@ -233,23 +409,6 @@
233
409
  <%= @order_by %> (<%= @order_direction == "ASC" ? "ascending" : "descending" %>)
234
410
  </span>
235
411
  <% end %>
236
-
237
- <%
238
- clear_sort_params = {
239
- page: 1,
240
- per_page: @per_page,
241
- column_filters: @column_filters
242
- }
243
-
244
- # Add creation filters if they exist
245
- clear_sort_params[:creation_filter_start] = @creation_filter_start if @creation_filter_start.present?
246
- clear_sort_params[:creation_filter_end] = @creation_filter_end if @creation_filter_end.present?
247
- %>
248
- <%= link_to "×", table_path(@table_name, clear_sort_params),
249
- class: "text-light ms-2",
250
- style: "text-decoration: none;",
251
- title: "Clear sorting" %>
252
- </span>
253
412
  <span class="badge bg-secondary">Total: <%= @total_count %> records</span>
254
413
  <% active_filters = @column_filters.reject { |_, v| v.blank? }.size %>
255
414
  <% if active_filters > 0 %>
@@ -268,192 +427,14 @@
268
427
 
269
428
  <table class="table table-bordered table-striped rounded-none">
270
429
  <thead class="dbviewer-table-header">
271
- <tr>
272
- <% if @records && @records.columns %>
273
- <% @records.columns.each do |column_name| %>
274
- <% is_sorted = @order_by == column_name %>
275
- <th class="px-3 py-2 sortable-column <%= 'sorted' if is_sorted %>">
276
- <%= sortable_column_header(column_name, @order_by, @order_direction, @table_name, @current_page, @per_page, @column_filters) %>
277
- </th>
278
- <% end %>
279
- <% else %>
280
- <th>No columns available</th>
281
- <% end %>
282
- </tr>
283
- <tr class="column-filters">
284
- <% if @records && @records.columns %>
285
- <% @records.columns.each do |column_name| %>
286
- <th class="p-0">
287
- <%
288
- # Find column info to detect the type
289
- column_info = @columns.find { |c| c[:name].to_s == column_name.to_s } if @columns
290
- column_type = column_info ? column_info[:type].to_s.downcase : nil
291
- %>
292
-
293
- <div class="filter-input-group">
294
- <%
295
- # Define the default operator and available operators based on column type
296
- default_operator = if column_type && column_type =~ /char|text|string|uuid|enum/i
297
- "contains"
298
- else
299
- "eq"
300
- end
301
-
302
- # Get previously selected operator or default
303
- selected_operator = @column_filters["#{column_name}_operator"]
304
- selected_operator = default_operator if selected_operator.nil? || selected_operator == "default"
305
-
306
- # Define operator options based on column type
307
- if column_type && (column_type =~ /datetime/ || column_type =~ /^date$/ || column_type =~ /^time$/)
308
- # Date/Time operators
309
- operator_options = [
310
- ["=", "eq"],
311
- ["≠", "neq"],
312
- ["<", "lt"],
313
- [">", "gt"],
314
- ["≤", "lte"],
315
- ["≥", "gte"]
316
- ]
317
- elsif column_type && column_type =~ /int|float|decimal|double|number|numeric|real|money|bigint|smallint|tinyint|mediumint|bit/i
318
- # Numeric operators
319
- operator_options = [
320
- ["=", "eq"],
321
- ["≠", "neq"],
322
- ["<", "lt"],
323
- [">", "gt"],
324
- ["≤", "lte"],
325
- ["≥", "gte"]
326
- ]
327
- else
328
- # Text operators
329
- operator_options = [
330
- ["contains", "contains"],
331
- ["not contains", "not_contains"],
332
- ["=", "eq"],
333
- ["≠", "neq"],
334
- ["starts with", "starts_with"],
335
- ["ends with", "ends_with"]
336
- ]
337
- end
338
- %>
339
-
340
- <%= form.select "column_filters[#{column_name}_operator]",
341
- options_for_select(operator_options, selected_operator),
342
- { include_blank: false },
343
- { class: "form-select form-select-sm operator-select" } %>
344
-
345
- <% if column_type && column_type =~ /datetime/ %>
346
- <%= form.datetime_local_field "column_filters[#{column_name}]",
347
- value: @column_filters[column_name],
348
- class: "form-control form-control-sm column-filter rounded-0",
349
- data: { column: column_name } %>
350
- <% elsif column_type && column_type =~ /^date$/ %>
351
- <%= form.date_field "column_filters[#{column_name}]",
352
- value: @column_filters[column_name],
353
- class: "form-control form-control-sm column-filter rounded-0",
354
- data: { column: column_name } %>
355
- <% elsif column_type && column_type =~ /^time$/ %>
356
- <%= form.time_field "column_filters[#{column_name}]",
357
- value: @column_filters[column_name],
358
- class: "form-control form-control-sm column-filter rounded-0",
359
- data: { column: column_name } %>
360
- <% else %>
361
- <%= form.text_field "column_filters[#{column_name}]",
362
- value: @column_filters[column_name],
363
- placeholder: "",
364
- class: "form-control form-control-sm column-filter rounded-0",
365
- data: { column: column_name } %>
366
- <% end %>
367
- </div>
368
- </th>
369
- <% end %>
370
- <% else %>
371
- <th></th>
372
- <% end %>
373
- </tr>
430
+ <%= render_sortable_header_row(@records, @order_by, @order_direction, @table_name, @current_page, @per_page, @column_filters) %>
431
+ <%= render_column_filters_row(form, @records, @columns, @column_filters) %>
374
432
  </thead>
375
- <tbody>
376
- <% if @records.nil? || @records.rows.nil? || @records.empty? %>
377
- <tr>
378
- <td colspan="100%" class="text-center">No records found or table is empty.</td>
379
- </tr>
380
- <% end %>
381
- <% if @records && @records.rows %>
382
- <% @records.rows.each do |row| %>
383
- <tr>
384
- <% row.each_with_index do |cell, cell_index| %>
385
- <%
386
- column_name = @records.columns[cell_index]
387
- cell_value = format_cell_value(cell)
388
-
389
- # Check if this column is a foreign key
390
- foreign_key = @metadata && @metadata[:foreign_keys] ?
391
- @metadata[:foreign_keys].find { |fk| fk[:column] == column_name } :
392
- nil
393
- %>
394
- <% if foreign_key && !cell.nil? %>
395
- <%
396
- # Build params for foreign key links with creation filters
397
- fk_params = { column_filters: { foreign_key[:primary_key] => cell } }
398
-
399
- # Add creation filters if they exist
400
- fk_params[:creation_filter_start] = @creation_filter_start if @creation_filter_start.present?
401
- fk_params[:creation_filter_end] = @creation_filter_end if @creation_filter_end.present?
402
- %>
403
- <td title="<%= cell_value %> (Click to view referenced record)">
404
- <%= link_to cell_value,
405
- table_path(foreign_key[:to_table], fk_params),
406
- class: "text-decoration-none foreign-key-link" %>
407
- <i class="bi bi-link-45deg text-muted small"></i>
408
- </td>
409
- <% else %>
410
- <td title="<%= cell_value %>"><%= cell_value %></td>
411
- <% end %>
412
- <% end %>
413
- </tr>
414
- <% end %>
415
- <% end %>
416
- </tbody>
433
+ <%= render_table_body(@records, @metadata) %>
417
434
  </table>
418
435
  <% end %> <!-- End of form_with -->
419
436
  </div>
420
-
421
- <% if @total_pages && @total_pages > 1 %>
422
- <%
423
- # Prepare common URL params including creation filters
424
- common_params = {
425
- order_by: @order_by,
426
- order_direction: @order_direction,
427
- per_page: @per_page,
428
- column_filters: @column_filters
429
- }
430
-
431
- # Add creation filters if they exist
432
- common_params[:creation_filter_start] = @creation_filter_start if @creation_filter_start.present?
433
- common_params[:creation_filter_end] = @creation_filter_end if @creation_filter_end.present?
434
- %>
435
- <nav aria-label="Page navigation">
436
- <ul class="pagination justify-content-center">
437
- <li class="page-item <%= 'disabled' if @current_page == 1 %>">
438
- <%= link_to '«', table_path(@table_name, common_params.merge(page: [@current_page - 1, 1].max)), class: 'page-link' %>
439
- </li>
440
-
441
- <% start_page = [1, @current_page - 2].max %>
442
- <% end_page = [start_page + 4, @total_pages].min %>
443
- <% start_page = [1, end_page - 4].max %>
444
-
445
- <% (start_page..end_page).each do |page_num| %>
446
- <li class="page-item <%= 'active' if page_num == @current_page %>">
447
- <%= link_to page_num, table_path(@table_name, common_params.merge(page: page_num)), class: 'page-link' %>
448
- </li>
449
- <% end %>
450
-
451
- <li class="page-item <%= 'disabled' if @current_page == @total_pages %>">
452
- <%= link_to '»', table_path(@table_name, common_params.merge(page: [@current_page + 1, @total_pages].min)), class: 'page-link' %>
453
- </li>
454
- </ul>
455
- </nav>
456
- <% end %>
437
+ <%= render_pagination(@table_name, @current_page, @total_pages, common_params) %>
457
438
  </div>
458
439
  </div>
459
440
  </div>
@@ -467,24 +448,7 @@
467
448
  <div class="card-header d-flex justify-content-between align-items-center">
468
449
  <h5 class="mb-0"><i class="bi bi-graph-up me-2"></i>Record Creation Timeline</h5>
469
450
  <div>
470
- <%
471
- # Build common params for time grouping links
472
- time_params = {
473
- page: @current_page,
474
- order_by: @order_by,
475
- order_direction: @order_direction,
476
- per_page: @per_page
477
- }
478
-
479
- # Add creation filters if they exist
480
- time_params[:creation_filter_start] = @creation_filter_start if @creation_filter_start.present?
481
- time_params[:creation_filter_end] = @creation_filter_end if @creation_filter_end.present?
482
- %>
483
- <div class="btn-group btn-group-sm" role="group" aria-label="Time grouping">
484
- <%= link_to "Hourly", table_path(@table_name, time_params.merge(time_group: "hourly")), class: "btn btn-outline-primary #{@time_grouping == 'hourly' ? 'active' : ''}" %>
485
- <%= link_to "Daily", table_path(@table_name, time_params.merge(time_group: "daily")), class: "btn btn-outline-primary #{@time_grouping == 'daily' ? 'active' : ''}" %>
486
- <%= link_to "Weekly", table_path(@table_name, time_params.merge(time_group: "weekly")), class: "btn btn-outline-primary #{@time_grouping == 'weekly' ? 'active' : ''}" %>
487
- </div>
451
+ <%= time_grouping_links(@table_name, @time_grouping) %>
488
452
  </div>
489
453
  </div>
490
454
  <div class="card-body">
@@ -528,8 +492,169 @@
528
492
  </div>
529
493
  </div>
530
494
 
495
+ <!-- Record Detail Modal -->
496
+ <div class="modal fade" id="recordDetailModal" tabindex="-1" aria-labelledby="recordDetailModalLabel" aria-hidden="true">
497
+ <div class="modal-dialog modal-lg modal-dialog-scrollable">
498
+ <div class="modal-content">
499
+ <div class="modal-header">
500
+ <h5 class="modal-title" id="recordDetailModalLabel"><%= @table_name %> Record Details</h5>
501
+ <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
502
+ </div>
503
+ <div class="modal-body">
504
+ <!-- Record Data Section -->
505
+ <div class="table-responsive">
506
+ <table class="table table-bordered record-detail-table">
507
+ <thead>
508
+ <tr>
509
+ <th width="30%">Column</th>
510
+ <th>Value</th>
511
+ </tr>
512
+ </thead>
513
+ <tbody id="recordDetailTableBody">
514
+ <!-- Record details will be inserted here dynamically -->
515
+ </tbody>
516
+ </table>
517
+ </div>
518
+
519
+ <!-- Relationships Section -->
520
+ <div id="relationshipsSection" class="mt-4" style="display: none;">
521
+ <h6 class="mb-3">
522
+ <i class="bi bi-link-45deg me-2"></i>Relationships
523
+ </h6>
524
+ <div id="relationshipsContent">
525
+ <!-- Relationships will be inserted here dynamically -->
526
+ </div>
527
+ </div>
528
+ </div>
529
+ <div class="modal-footer">
530
+ <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
531
+ </div>
532
+ </div>
533
+ </div>
534
+ </div>
535
+
531
536
  <script>
532
537
  document.addEventListener('DOMContentLoaded', function() {
538
+ // Record Detail Modal functionality
539
+ const recordDetailModal = document.getElementById('recordDetailModal');
540
+ if (recordDetailModal) {
541
+ recordDetailModal.addEventListener('show.bs.modal', function (event) {
542
+ // Button that triggered the modal
543
+ const button = event.relatedTarget;
544
+
545
+ // Extract record data from button's data attribute
546
+ let recordData;
547
+ let foreignKeys;
548
+ try {
549
+ recordData = JSON.parse(button.getAttribute('data-record-data'));
550
+ foreignKeys = JSON.parse(button.getAttribute('data-foreign-keys') || '[]');
551
+ } catch (e) {
552
+ console.error('Error parsing record data:', e);
553
+ recordData = {};
554
+ foreignKeys = [];
555
+ }
556
+
557
+ // Update the modal's title with table name
558
+ const modalTitle = recordDetailModal.querySelector('.modal-title');
559
+ modalTitle.textContent = '<%= @table_name %> Record Details';
560
+
561
+ // Populate the table with record data
562
+ const tableBody = document.getElementById('recordDetailTableBody');
563
+ tableBody.innerHTML = '';
564
+
565
+ // Get all columns
566
+ const columns = Object.keys(recordData);
567
+
568
+ // Create rows for each column
569
+ columns.forEach(column => {
570
+ const row = document.createElement('tr');
571
+
572
+ // Create column name cell
573
+ const columnNameCell = document.createElement('td');
574
+ columnNameCell.className = 'fw-bold';
575
+ columnNameCell.textContent = column;
576
+ row.appendChild(columnNameCell);
577
+
578
+ // Create value cell
579
+ const valueCell = document.createElement('td');
580
+ let cellValue = recordData[column];
581
+
582
+ // Format value differently based on type
583
+ if (cellValue === null) {
584
+ valueCell.innerHTML = '<span class="text-muted">NULL</span>';
585
+ } else if (typeof cellValue === 'string' && cellValue.match(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/)) {
586
+ // Handle datetime values
587
+ const date = new Date(cellValue);
588
+ if (!isNaN(date.getTime())) {
589
+ valueCell.textContent = date.toLocaleString();
590
+ } else {
591
+ valueCell.textContent = cellValue;
592
+ }
593
+ } else if (typeof cellValue === 'string' && (cellValue.startsWith('{') || cellValue.startsWith('['))) {
594
+ // Handle JSON values
595
+ try {
596
+ const jsonValue = JSON.parse(cellValue);
597
+ const formattedJSON = JSON.stringify(jsonValue, null, 2);
598
+ valueCell.innerHTML = `<pre class="mb-0 code-block">${formattedJSON}</pre>`;
599
+ } catch (e) {
600
+ valueCell.textContent = cellValue;
601
+ }
602
+ } else {
603
+ valueCell.textContent = cellValue;
604
+ }
605
+
606
+ row.appendChild(valueCell);
607
+ tableBody.appendChild(row);
608
+ });
609
+
610
+ // Populate relationships section
611
+ const relationshipsSection = document.getElementById('relationshipsSection');
612
+ const relationshipsContent = document.getElementById('relationshipsContent');
613
+ const reverseForeignKeys = JSON.parse(button.dataset.reverseForeignKeys || '[]');
614
+
615
+ // Check if we have any relationships to show
616
+ const hasRelationships = (foreignKeys && foreignKeys.length > 0) || (reverseForeignKeys && reverseForeignKeys.length > 0);
617
+
618
+ if (hasRelationships) {
619
+ relationshipsSection.style.display = 'block';
620
+ relationshipsContent.innerHTML = '';
621
+
622
+ // Handle belongs_to relationships (foreign keys from this table)
623
+ if (foreignKeys && foreignKeys.length > 0) {
624
+ const activeRelationships = foreignKeys.filter(fk => {
625
+ const columnValue = recordData[fk.column];
626
+ return columnValue !== null && columnValue !== undefined && columnValue !== '';
627
+ });
628
+
629
+ if (activeRelationships.length > 0) {
630
+ relationshipsContent.appendChild(createRelationshipSection('Belongs To', activeRelationships, recordData, 'belongs_to'));
631
+ }
632
+ }
633
+
634
+ // Handle has_many relationships (foreign keys from other tables pointing to this table)
635
+ if (reverseForeignKeys && reverseForeignKeys.length > 0) {
636
+ const primaryKeyValue = recordData[Object.keys(recordData).find(key => key === 'id') || Object.keys(recordData)[0]];
637
+
638
+ if (primaryKeyValue !== null && primaryKeyValue !== undefined && primaryKeyValue !== '') {
639
+ relationshipsContent.appendChild(createRelationshipSection('Has Many', reverseForeignKeys, recordData, 'has_many', primaryKeyValue));
640
+ }
641
+ }
642
+
643
+ // Show message if no active relationships
644
+ if (relationshipsContent.children.length === 0) {
645
+ relationshipsContent.innerHTML = `
646
+ <div class="text-muted small">
647
+ <i class="bi bi-info-circle me-1"></i>
648
+ This record has no active relationships.
649
+ </div>
650
+ `;
651
+ }
652
+ } else {
653
+ relationshipsSection.style.display = 'none';
654
+ }
655
+ });
656
+ }
657
+
533
658
  // Column filter functionality
534
659
  const columnFilters = document.querySelectorAll('.column-filter');
535
660
  const operatorSelects = document.querySelectorAll('.operator-select');
@@ -1031,112 +1156,101 @@
1031
1156
  background-color: var(--bs-tertiary-bg, #f8f9fa);
1032
1157
  }
1033
1158
 
1034
- .column-filter {
1035
- width: 100%;
1036
- border: 1px solid rgba(0,0,0,0.1);
1037
- padding: 0.3rem 0.5rem;
1038
- font-size: 0.85rem;
1159
+ /* Action column styling */
1160
+ .action-column {
1161
+ width: 60px;
1162
+ min-width: 60px; /* Ensure minimum width */
1163
+ white-space: nowrap;
1164
+ position: sticky;
1165
+ left: 0;
1166
+ z-index: 30; /* Increased z-index to ensure it stays on top */
1167
+ background-color: var(--bs-body-bg, #fff); /* Use body background color */
1168
+ box-shadow: 2px 0 4px rgba(0, 0, 0, 0.1);
1169
+ border-right: 1px solid var(--bs-border-color);
1039
1170
  }
1040
1171
 
1041
- /* Filter input group styling */
1042
- .filter-input-group {
1043
- display: flex;
1044
- flex-direction: column;
1045
- width: 100%;
1172
+ /* Ensure proper background color for actions column in dark mode */
1173
+ [data-bs-theme="dark"] .action-column {
1174
+ background-color: var(--bs-body-bg, #212529); /* Use body background in dark mode */
1046
1175
  }
1047
-
1048
- .filter-input-group .operator-select {
1049
- font-size: 0.7rem;
1050
- padding: 0.1rem 0.5rem;
1051
- border-bottom: none;
1052
- border-radius: 0.2rem 0.2rem 0 0;
1053
- background-color: rgba(0,0,0,0.03);
1054
- font-weight: normal;
1055
- min-height: 22px; /* Ensure consistent height */
1176
+
1177
+ /* Maintain zebra striping with sticky action column */
1178
+ .table-striped > tbody > tr:nth-of-type(odd) > .action-column {
1179
+ background-color: var(--bs-tertiary-bg, #f8f9fa);
1056
1180
  }
1057
1181
 
1058
- .filter-input-group .operator-select option {
1059
- font-size: 0.8rem;
1060
- padding: 2px;
1182
+ .table-striped > tbody > tr:nth-of-type(even) > .action-column {
1183
+ background-color: var(--bs-body-bg, #fff);
1061
1184
  }
1062
-
1063
- [data-bs-theme="dark"] .filter-input-group .operator-select {
1064
- background-color: rgba(255,255,255,0.05);
1065
- color: rgba(255,255,255,0.9);
1066
- border-color: rgba(255,255,255,0.15);
1185
+
1186
+ [data-bs-theme="dark"] .table-striped > tbody > tr:nth-of-type(odd) > .action-column {
1187
+ background-color: var(--bs-tertiary-bg, #2b3035);
1067
1188
  }
1068
1189
 
1069
- .filter-input-group .column-filter {
1070
- border-radius: 0 0 0.2rem 0.2rem;
1071
- border-top: none;
1190
+ [data-bs-theme="dark"] .table-striped > tbody > tr:nth-of-type(even) > .action-column {
1191
+ background-color: var(--bs-body-bg, #212529);
1072
1192
  }
1073
1193
 
1074
- /* Ensure consistent sizing for date/time inputs */
1075
- input[type="date"].column-filter,
1076
- input[type="datetime-local"].column-filter,
1077
- input[type="time"].column-filter {
1078
- padding: 0.2rem 0.5rem;
1194
+ .view-record-btn {
1195
+ padding: 0.1rem 0.4rem;
1196
+ width: 32px;
1079
1197
  }
1080
1198
 
1081
- [data-bs-theme="dark"] .column-filters td {
1082
- background-color: rgba(255,255,255,0.05);
1199
+ .view-record-btn:hover {
1200
+ opacity: 0.85;
1201
+ transform: translateY(-1px);
1083
1202
  }
1084
1203
 
1085
- [data-bs-theme="dark"] .column-filter {
1086
- background-color: rgba(255,255,255,0.1);
1087
- color: rgba(255,255,255,0.9);
1088
- border-color: rgba(255,255,255,0.15);
1204
+ /* Record detail modal styling */
1205
+ .record-detail-table tr:first-child th,
1206
+ .record-detail-table tr:first-child td {
1207
+ border-top: none;
1089
1208
  }
1090
1209
 
1091
- /* Special styling for date/time inputs in dark mode */
1092
- [data-bs-theme="dark"] input[type="datetime-local"].column-filter::-webkit-calendar-picker-indicator,
1093
- [data-bs-theme="dark"] input[type="date"].column-filter::-webkit-calendar-picker-indicator,
1094
- [data-bs-theme="dark"] input[type="time"].column-filter::-webkit-calendar-picker-indicator {
1095
- filter: invert(0.8);
1210
+ .record-detail-table .code-block {
1211
+ background-color: var(--bs-light);
1212
+ padding: 0.5rem;
1213
+ border-radius: 0.25rem;
1214
+ overflow-x: auto;
1215
+ max-height: 200px;
1096
1216
  }
1097
1217
 
1098
- /* Mini ERD modal styling */
1099
- #miniErdModal .modal-dialog {
1100
- max-width: 90%;
1101
- max-height: 90vh;
1102
- height: 90vh;
1218
+ /* Relationships section styling */
1219
+ #relationshipsSection {
1220
+ border-top: 1px solid var(--bs-border-color);
1221
+ padding-top: 1rem;
1103
1222
  }
1104
1223
 
1105
- #miniErdModal .modal-content {
1106
- height: 100%;
1224
+ #relationshipsSection h6 {
1225
+ color: var(--bs-emphasis-color);
1226
+ margin-bottom: 1rem;
1107
1227
  }
1108
1228
 
1109
- #miniErdModal .modal-body {
1110
- height: calc(100% - 130px); /* Account for header and footer */
1111
- overflow: hidden; /* Prevent scrollbars within the modal body */
1229
+ [data-bs-theme="dark"] #relationshipsSection {
1230
+ border-top-color: #495057;
1112
1231
  }
1113
1232
 
1114
- #miniErdModal #mini-erd-container {
1115
- height: 100%;
1116
- width: 100%;
1233
+ .relationships-table .btn-outline-primary {
1234
+ font-size: 0.75rem;
1235
+ padding: 0.25rem 0.5rem;
1117
1236
  }
1118
1237
 
1119
- #miniErdModal #mini-erd-container svg {
1120
- width: 100%;
1121
- height: 100%;
1122
- max-height: unset;
1238
+ .relationships-table code {
1239
+ background-color: var(--bs-gray-100);
1240
+ padding: 0.125rem 0.25rem;
1241
+ border-radius: 0.125rem;
1242
+ font-size: 0.875rem;
1123
1243
  }
1124
1244
 
1125
- /* Foreign key link styling */
1126
- .foreign-key-link {
1127
- color: var(--bs-primary);
1128
- position: relative;
1245
+ [data-bs-theme="dark"] .relationships-table code {
1246
+ background-color: var(--bs-gray-800);
1247
+ color: var(--bs-gray-100);
1129
1248
  }
1130
-
1131
- .foreign-key-link:hover {
1132
- text-decoration: underline !important;
1249
+ margin-bottom: 0;
1133
1250
  }
1134
1251
 
1135
- .foreign-key-link + .bi-link-45deg {
1136
- font-size: 0.75rem;
1137
- margin-left: 0.25rem;
1138
- position: relative;
1139
- top: -1px;
1252
+ [data-bs-theme="dark"] .record-detail-table .code-block {
1253
+ background-color: var(--bs-dark);
1140
1254
  }
1141
1255
  </style>
1142
1256
 
@@ -1250,5 +1364,98 @@
1250
1364
  }
1251
1365
  });
1252
1366
  });
1367
+
1368
+ // Helper function to create relationship sections
1369
+ function createRelationshipSection(title, relationships, recordData, type, primaryKeyValue = null) {
1370
+ const section = document.createElement('div');
1371
+ section.className = 'relationship-section mb-4';
1372
+
1373
+ // Create section header
1374
+ const header = document.createElement('h6');
1375
+ header.className = 'mb-3';
1376
+ const icon = type === 'belongs_to' ? 'bi-arrow-up-right' : 'bi-arrow-down-left';
1377
+ header.innerHTML = `<i class="bi ${icon} me-2"></i>${title}`;
1378
+ section.appendChild(header);
1379
+
1380
+ const tableContainer = document.createElement('div');
1381
+ tableContainer.className = 'table-responsive';
1382
+
1383
+ const table = document.createElement('table');
1384
+ table.className = 'table table-sm table-bordered';
1385
+
1386
+ // Create header based on relationship type
1387
+ const thead = document.createElement('thead');
1388
+ if (type === 'belongs_to') {
1389
+ thead.innerHTML = `
1390
+ <tr>
1391
+ <th width="25%">Column</th>
1392
+ <th width="25%">Value</th>
1393
+ <th width="25%">References</th>
1394
+ <th width="25%">Action</th>
1395
+ </tr>
1396
+ `;
1397
+ } else {
1398
+ thead.innerHTML = `
1399
+ <tr>
1400
+ <th width="30%">Related Table</th>
1401
+ <th width="25%">Foreign Key</th>
1402
+ <th width="20%">Count</th>
1403
+ <th width="25%">Action</th>
1404
+ </tr>
1405
+ `;
1406
+ }
1407
+ table.appendChild(thead);
1408
+
1409
+ // Create body
1410
+ const tbody = document.createElement('tbody');
1411
+
1412
+ relationships.forEach(fk => {
1413
+ const row = document.createElement('tr');
1414
+
1415
+ if (type === 'belongs_to') {
1416
+ const columnValue = recordData[fk.column];
1417
+ row.innerHTML = `
1418
+ <td class="fw-medium">${fk.column}</td>
1419
+ <td><code>${columnValue}</code></td>
1420
+ <td>
1421
+ <span class="text-muted">${fk.to_table}.</span><strong>${fk.primary_key}</strong>
1422
+ </td>
1423
+ <td>
1424
+ <a href="/dbviewer/tables/${fk.to_table}?column_filters[${fk.primary_key}]=${encodeURIComponent(columnValue)}"
1425
+ class="btn btn-sm btn-outline-primary"
1426
+ title="View referenced record in ${fk.to_table}">
1427
+ <i class="bi bi-arrow-right me-1"></i>View
1428
+ </a>
1429
+ </td>
1430
+ `;
1431
+ } else {
1432
+ // For has_many relationships
1433
+ row.innerHTML = `
1434
+ <td class="fw-medium">${fk.from_table}</td>
1435
+ <td>
1436
+ <span class="text-muted">${fk.from_table}.</span><strong>${fk.column}</strong>
1437
+ </td>
1438
+ <td>
1439
+ <span class="badge bg-secondary">View All</span>
1440
+ </td>
1441
+ <td>
1442
+ <a href="/dbviewer/tables/${fk.from_table}?column_filters[${fk.column}]=${encodeURIComponent(primaryKeyValue)}"
1443
+ class="btn btn-sm btn-outline-success"
1444
+ title="View all ${fk.from_table} records that reference this record">
1445
+ <i class="bi bi-list me-1"></i>View Related
1446
+ </a>
1447
+ </td>
1448
+ `;
1449
+ }
1450
+
1451
+ tbody.appendChild(row);
1452
+ });
1453
+
1454
+ table.appendChild(tbody);
1455
+ tableContainer.appendChild(table);
1456
+ section.appendChild(tableContainer);
1457
+
1458
+ return section;
1459
+ }
1253
1460
  </script>
1254
1461
  <% end %>