dbviewer 0.3.6 → 0.3.16

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.
@@ -2,15 +2,136 @@
2
2
  Table: <%= @table_name %>
3
3
  <% end %>
4
4
 
5
+ <% content_for :head do %>
6
+ <!-- Mermaid.js library for ERD diagrams -->
7
+ <script src="https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js"></script>
8
+ <!-- SVG-Pan-Zoom for interactive diagram navigation -->
9
+ <script src="https://cdn.jsdelivr.net/npm/svg-pan-zoom@3.6.1/dist/svg-pan-zoom.min.js"></script>
10
+ <style>
11
+ /* Column sorting styles */
12
+ .sortable-column {
13
+ cursor: pointer;
14
+ position: relative;
15
+ transition: background-color 0.2s ease;
16
+ background-color: inherit;
17
+ }
18
+
19
+ .sortable-column:hover {
20
+ background-color: #f5f5f5;
21
+ }
22
+
23
+ .sortable-column.sorted {
24
+ background-color: #f0f0f0;
25
+ }
26
+
27
+ .sortable-column .column-sort-link {
28
+ display: flex;
29
+ align-items: center;
30
+ justify-content: space-between;
31
+ width: 100%;
32
+ height: 100%;
33
+ padding: 4px 0;
34
+ }
35
+
36
+ .sortable-column .column-name {
37
+ flex: 1;
38
+ overflow: hidden;
39
+ text-overflow: ellipsis;
40
+ white-space: nowrap;
41
+ }
42
+
43
+ .sortable-column .sort-icon-container {
44
+ flex: 0 0 auto;
45
+ width: 20px;
46
+ text-align: center;
47
+ margin-left: 4px;
48
+ }
49
+
50
+ .sortable-column .sort-icon {
51
+ font-size: 0.8em;
52
+ opacity: 0.7;
53
+ transition: opacity 0.2s ease, color 0.2s ease;
54
+ }
55
+
56
+ .sortable-column:hover .sort-icon.invisible {
57
+ visibility: visible !important;
58
+ opacity: 0.3;
59
+ }
60
+
61
+ /* Fix scrolling issues with sticky header */
62
+ .dbviewer-table-header {
63
+ position: sticky !important;
64
+ top: 0;
65
+ z-index: 10;
66
+ background-color: var(--bs-table-striped-bg, #f2f2f2) !important;
67
+ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.075);
68
+ }
69
+
70
+ [data-bs-theme="dark"] .dbviewer-table-header {
71
+ background-color: var(--bs-dark-bg-subtle, #343a40) !important;
72
+ }
73
+
74
+ /* Improve mobile display for sort headers */
75
+ @media (max-width: 767.98px) {
76
+ .sortable-column .column-sort-link {
77
+ flex-direction: row;
78
+ align-items: center;
79
+ }
80
+
81
+ .sortable-column .sort-icon-container {
82
+ width: 16px;
83
+ }
84
+ }
85
+
86
+ /* Dark mode compatibility */
87
+ [data-bs-theme="dark"] .sortable-column:hover {
88
+ background-color: rgba(255, 255, 255, 0.05);
89
+ }
90
+
91
+ [data-bs-theme="dark"] .sortable-column.sorted {
92
+ background-color: rgba(255, 255, 255, 0.1);
93
+ }
94
+ </style>
95
+
96
+ <script>
97
+ // Initialize mermaid when document is ready
98
+ document.addEventListener('DOMContentLoaded', function() {
99
+ // Configure Mermaid for better ERD diagrams
100
+ mermaid.initialize({
101
+ startOnLoad: false,
102
+ theme: document.documentElement.getAttribute('data-bs-theme') === 'dark' ? 'dark' : 'default',
103
+ securityLevel: 'loose',
104
+ er: {
105
+ diagramPadding: 20,
106
+ layoutDirection: 'TB',
107
+ minEntityWidth: 100,
108
+ minEntityHeight: 75,
109
+ entityPadding: 15,
110
+ stroke: 'gray',
111
+ fill: document.documentElement.getAttribute('data-bs-theme') === 'dark' ? '#2D3748' : '#f5f5f5',
112
+ fontSize: 14,
113
+ useMaxWidth: true,
114
+ wrapiength: 30
115
+ }
116
+ });
117
+ console.log('Mermaid initialized with theme:', document.documentElement.getAttribute('data-bs-theme') === 'dark' ? 'dark' : 'default');
118
+ });
119
+ </script>
120
+ <% end %>
121
+
5
122
  <% content_for :sidebar_active do %>active<% end %>
6
123
 
7
124
  <% content_for :sidebar do %>
8
125
  <%= render 'dbviewer/shared/sidebar' %>
9
- <% end %>
10
-
126
+ <% end %>
11
127
  <div class="d-flex justify-content-between align-items-center mb-4">
12
- <h1>Table: <%= @table_name %></h1>
128
+ <div>
129
+ <h1>Table: <%= @table_name %></h1>
130
+ </div>
13
131
  <div class="d-flex gap-2">
132
+ <button type="button" class="btn btn-secondary" data-bs-toggle="modal" data-bs-target="#miniErdModal">
133
+ <i class="bi bi-diagram-3 me-1"></i> View Relationships
134
+ </button>
14
135
  <% if Dbviewer.configuration.enable_data_export %>
15
136
  <button type="button" class="btn btn-success" data-bs-toggle="modal" data-bs-target="#csvExportModal">
16
137
  <i class="bi bi-file-earmark-spreadsheet me-1"></i> Export CSV
@@ -63,19 +184,72 @@
63
184
  </div>
64
185
  <% end %>
65
186
 
187
+ <!-- Mini ERD Modal -->
188
+ <div class="modal fade" id="miniErdModal" tabindex="-1" aria-labelledby="miniErdModalLabel" aria-hidden="true">
189
+ <div class="modal-dialog modal-xl modal-dialog-centered">
190
+ <div class="modal-content" id="miniErdModalContent">
191
+ <!-- Content will be loaded dynamically -->
192
+ <div class="modal-body text-center p-0">
193
+ <div id="mini-erd-container" class="w-100 d-flex justify-content-center align-items-center" style="min-height: 450px; height: 100%;">
194
+ <div class="text-center">
195
+ <div class="spinner-border text-primary" role="status">
196
+ <span class="visually-hidden">Loading...</span>
197
+ </div>
198
+ <p class="mt-2">Loading relationships diagram...</p>
199
+ </div>
200
+ </div>
201
+ </div>
202
+ </div>
203
+ </div>
204
+ </div>
205
+
66
206
  <!-- Records Section -->
67
207
  <div class="dbviewer-card card mb-4">
68
208
  <div class="card-header d-flex justify-content-between align-items-center">
69
- <h5 class="mb-0"><i class="bi bi-table me-2"></i>Records</h5>
70
- <div class="d-flex align-items-center">
71
- <div class="me-3">
72
- <label for="per-page-select" class="me-2">Per page:</label>
73
- <select id="per-page-select" class="form-select form-select-sm" onchange="window.location.href='<%= table_path(@table_name) %>?per_page=' + this.value + '&page=1&order_by=<%= @order_by %>&order_direction=<%= @order_direction %><%= @column_filters.reject { |_, v| v.blank? }.any? ? "&" + @column_filters.reject { |_, v| v.blank? }.map { |k, v| "column_filters[#{k}]=#{CGI.escape(v.to_s)}" }.join("&") : "" %>'">
74
- <% Dbviewer::TablesController.per_page_options.each do |option| %>
75
- <option value="<%= option %>" <%= 'selected' if @per_page == option %>><%= option %></option>
76
- <% end %>
77
- </select>
78
- </div>
209
+ <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 %>'">
224
+ <% Dbviewer::TablesController.per_page_options.each do |option| %>
225
+ <option value="<%= option %>" <%= 'selected' if @per_page == option %>><%= option %></option>
226
+ <% end %>
227
+ </select>
228
+ </h5>
229
+ <div class="d-flex align-items-center table-actions">
230
+ <% if @order_by.present? %>
231
+ <span class="badge bg-primary me-2" title="Sort order">
232
+ <i class="bi bi-sort-<%= @order_direction == "ASC" ? "up" : "down" %> me-1"></i>
233
+ <%= @order_by %> (<%= @order_direction == "ASC" ? "ascending" : "descending" %>)
234
+ </span>
235
+ <% 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>
79
253
  <span class="badge bg-secondary">Total: <%= @total_count %> records</span>
80
254
  <% active_filters = @column_filters.reject { |_, v| v.blank? }.size %>
81
255
  <% if active_filters > 0 %>
@@ -95,48 +269,173 @@
95
269
  <table class="table table-bordered table-striped rounded-none">
96
270
  <thead class="dbviewer-table-header">
97
271
  <tr>
98
- <% @records.columns.each do |column_name| %>
99
- <th class="pe-4">
100
- <%= column_name %>
101
- </th>
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>
102
281
  <% end %>
103
282
  </tr>
104
283
  <tr class="column-filters">
105
- <% @records.columns.each do |column_name| %>
106
- <th class="p-0">
107
- <%= form.text_field "column_filters[#{column_name}]",
108
- value: @column_filters[column_name],
109
- placeholder: "",
110
- class: "form-control form-control-sm column-filter rounded-0",
111
- data: { column: column_name } %>
112
- </th>
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>
113
372
  <% end %>
114
373
  </tr>
115
374
  </thead>
116
375
  <tbody>
117
- <% if @records.empty? %>
376
+ <% if @records.nil? || @records.rows.nil? || @records.empty? %>
118
377
  <tr>
119
378
  <td colspan="100%" class="text-center">No records found or table is empty.</td>
120
379
  </tr>
121
380
  <% end %>
122
- <% @records.rows.each do |row| %>
123
- <tr>
124
- <% row.each do |cell| %>
125
- <% cell_value = format_cell_value(cell) %>
126
- <td title="<%= cell_value %>"><%= cell_value %></td>
127
- <% end %>
128
- </tr>
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 %>
129
415
  <% end %>
130
416
  </tbody>
131
417
  </table>
132
418
  <% end %> <!-- End of form_with -->
133
419
  </div>
134
420
 
135
- <% if @total_pages > 1 %>
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
+ %>
136
435
  <nav aria-label="Page navigation">
137
436
  <ul class="pagination justify-content-center">
138
437
  <li class="page-item <%= 'disabled' if @current_page == 1 %>">
139
- <%= link_to '«', table_path(@table_name, page: [@current_page - 1, 1].max, order_by: @order_by, order_direction: @order_direction, per_page: @per_page, column_filters: @column_filters), class: 'page-link' %>
438
+ <%= link_to '«', table_path(@table_name, common_params.merge(page: [@current_page - 1, 1].max)), class: 'page-link' %>
140
439
  </li>
141
440
 
142
441
  <% start_page = [1, @current_page - 2].max %>
@@ -145,12 +444,12 @@
145
444
 
146
445
  <% (start_page..end_page).each do |page_num| %>
147
446
  <li class="page-item <%= 'active' if page_num == @current_page %>">
148
- <%= link_to page_num, table_path(@table_name, page: page_num, order_by: @order_by, order_direction: @order_direction, per_page: @per_page, column_filters: @column_filters), class: 'page-link' %>
447
+ <%= link_to page_num, table_path(@table_name, common_params.merge(page: page_num)), class: 'page-link' %>
149
448
  </li>
150
449
  <% end %>
151
450
 
152
451
  <li class="page-item <%= 'disabled' if @current_page == @total_pages %>">
153
- <%= link_to '»', table_path(@table_name, page: [@current_page + 1, @total_pages].min, order_by: @order_by, order_direction: @order_direction, per_page: @per_page, column_filters: @column_filters), class: 'page-link' %>
452
+ <%= link_to '»', table_path(@table_name, common_params.merge(page: [@current_page + 1, @total_pages].min)), class: 'page-link' %>
154
453
  </li>
155
454
  </ul>
156
455
  </nav>
@@ -168,10 +467,23 @@
168
467
  <div class="card-header d-flex justify-content-between align-items-center">
169
468
  <h5 class="mb-0"><i class="bi bi-graph-up me-2"></i>Record Creation Timeline</h5>
170
469
  <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
+ %>
171
483
  <div class="btn-group btn-group-sm" role="group" aria-label="Time grouping">
172
- <%= link_to "Hourly", table_path(@table_name, time_group: "hourly", page: @current_page, order_by: @order_by, order_direction: @order_direction, per_page: @per_page), class: "btn btn-outline-primary #{@time_grouping == 'hourly' ? 'active' : ''}" %>
173
- <%= link_to "Daily", table_path(@table_name, time_group: "daily", page: @current_page, order_by: @order_by, order_direction: @order_direction, per_page: @per_page), class: "btn btn-outline-primary #{@time_grouping == 'daily' ? 'active' : ''}" %>
174
- <%= link_to "Weekly", table_path(@table_name, time_group: "weekly", page: @current_page, order_by: @order_by, order_direction: @order_direction, per_page: @per_page), class: "btn btn-outline-primary #{@time_grouping == 'weekly' ? 'active' : ''}" %>
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' : ''}" %>
175
487
  </div>
176
488
  </div>
177
489
  </div>
@@ -220,6 +532,7 @@
220
532
  document.addEventListener('DOMContentLoaded', function() {
221
533
  // Column filter functionality
222
534
  const columnFilters = document.querySelectorAll('.column-filter');
535
+ const operatorSelects = document.querySelectorAll('.operator-select');
223
536
  const filterForm = document.getElementById('column-filters-form');
224
537
 
225
538
  // Add debounce function to reduce form submissions
@@ -242,7 +555,18 @@
242
555
 
243
556
  // Add event listeners to all filter inputs
244
557
  columnFilters.forEach(function(filter) {
558
+ // For text fields use input event
245
559
  filter.addEventListener('input', submitForm);
560
+
561
+ // For date/time fields also use change event since they have calendar/time pickers
562
+ if (filter.type === 'date' || filter.type === 'datetime-local' || filter.type === 'time') {
563
+ filter.addEventListener('change', submitForm);
564
+ }
565
+ });
566
+
567
+ // Add event listeners to operator selects
568
+ operatorSelects.forEach(function(select) {
569
+ select.addEventListener('change', submitForm);
246
570
  });
247
571
 
248
572
  // Add clear button functionality if there are any filters applied
@@ -262,11 +586,441 @@
262
586
  paginationContainer.insertAdjacentHTML('afterend', clearButton.outerHTML);
263
587
 
264
588
  document.getElementById('clear-filters').addEventListener('click', function() {
589
+ // Reset all input values
265
590
  columnFilters.forEach(filter => filter.value = '');
591
+
592
+ // Reset operator selects to their default values
593
+ operatorSelects.forEach(select => {
594
+ // Find the first option of the select (usually the default)
595
+ if (select.options.length > 0) {
596
+ select.selectedIndex = 0;
597
+ }
598
+ });
599
+
266
600
  submitForm();
267
601
  });
268
602
  }
269
603
  }
604
+
605
+ // Load Mini ERD when modal is opened
606
+ const miniErdModal = document.getElementById('miniErdModal');
607
+ if (miniErdModal) {
608
+ let isModalLoaded = false;
609
+ let erdData = null;
610
+
611
+ miniErdModal.addEventListener('show.bs.modal', function(event) {
612
+ const modalContent = document.getElementById('miniErdModalContent');
613
+
614
+ // Set loading state
615
+ modalContent.innerHTML = `
616
+ <div class="modal-header">
617
+ <h5 class="modal-title">Relationships for <%= @table_name %></h5>
618
+ <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
619
+ </div>
620
+ <div class="modal-body p-0">
621
+ <div id="mini-erd-container" class="w-100 d-flex justify-content-center align-items-center" style="min-height: 450px; height: 100%;">
622
+ <div class="text-center">
623
+ <div class="spinner-border text-primary mb-3" role="status">
624
+ <span class="visually-hidden">Loading...</span>
625
+ </div>
626
+ <p class="mt-2">Loading relationships diagram...</p>
627
+ <small class="text-muted">This may take a moment for tables with many relationships</small>
628
+ </div>
629
+ </div>
630
+ </div>
631
+ `;
632
+
633
+ // Always fetch fresh data when modal is opened
634
+ fetchErdData();
635
+ });
636
+
637
+ // Function to fetch ERD data
638
+ function fetchErdData() {
639
+ // Add cache-busting timestamp to prevent browser caching
640
+ const cacheBuster = new Date().getTime();
641
+ const fetchUrl = `<%= dbviewer.mini_erd_table_path(@table_name, format: :json) %>?_=${cacheBuster}`;
642
+
643
+ // Log loading message
644
+ console.log('Loading fresh Mini ERD data from:', fetchUrl);
645
+
646
+ // Set a timeout to handle long-running requests
647
+ const timeoutPromise = new Promise((_, reject) =>
648
+ setTimeout(() => reject(new Error('Request timeout after 10 seconds')), 10000)
649
+ );
650
+
651
+ // Race the fetch against a timeout
652
+ Promise.race([
653
+ fetch(fetchUrl),
654
+ timeoutPromise
655
+ ])
656
+ .then(response => {
657
+ if (!response.ok) {
658
+ throw new Error(`Server returned ${response.status} ${response.statusText}`);
659
+ }
660
+ return response.json(); // Parse as JSON instead of text
661
+ })
662
+ .then(data => {
663
+ isModalLoaded = true;
664
+ erdData = data; // Store the data
665
+ renderMiniErd(data);
666
+ })
667
+ .catch(error => {
668
+ console.error('Error loading mini ERD:', error);
669
+ showErdError(error);
670
+ });
671
+ }
672
+
673
+ // Function to show error modal
674
+ function showErdError(error) {
675
+ const modalContent = document.getElementById('miniErdModalContent');
676
+ modalContent.innerHTML = `
677
+ <div class="modal-header">
678
+ <h5 class="modal-title">Relationships for <%= @table_name %></h5>
679
+ <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
680
+ </div>
681
+ <div class="modal-body p-0">
682
+ <div class="alert alert-danger m-3">
683
+ <i class="bi bi-exclamation-triangle-fill me-2"></i>
684
+ <strong>Error loading relationship diagram</strong>
685
+ <p class="mt-2 mb-0">${error.message}</p>
686
+ </div>
687
+ <div class="m-3">
688
+ <p><strong>Debug Information:</strong></p>
689
+ <code>GET <%= dbviewer.mini_erd_table_path(@table_name, format: :json) %></code> failed
690
+ <p class="mt-3">
691
+ <button class="btn btn-sm btn-primary" onclick="retryLoadingMiniERD()">
692
+ <i class="bi bi-arrow-clockwise me-1"></i> Retry
693
+ </button>
694
+ </p>
695
+ </div>
696
+ </div>
697
+ <div class="modal-footer">
698
+ <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
699
+ </div>
700
+ `;
701
+ }
702
+
703
+ // Function to render the ERD with Mermaid
704
+ function renderMiniErd(data) {
705
+ const modalContent = document.getElementById('miniErdModalContent');
706
+
707
+ // Set up the modal content with container for ERD
708
+ modalContent.innerHTML = `
709
+ <div class="modal-header">
710
+ <h5 class="modal-title">Relationships for <%= @table_name %></h5>
711
+ <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
712
+ </div>
713
+ <div class="modal-body p-0"> <!-- Removed padding for full width -->
714
+ <div id="mini-erd-container" class="w-100" style="min-height: 450px; height: 100%;"> <!-- Increased height -->
715
+ <div id="mini-erd-loading" class="d-flex justify-content-center align-items-center" style="height: 100%; min-height: 450px;">
716
+ <div class="text-center">
717
+ <div class="spinner-border text-primary mb-3" role="status">
718
+ <span class="visually-hidden">Loading...</span>
719
+ </div>
720
+ <p>Generating Relationships Diagram...</p>
721
+ </div>
722
+ </div>
723
+ <div id="mini-erd-error" class="alert alert-danger m-3 d-none">
724
+ <h5>Error generating diagram</h5>
725
+ <p id="mini-erd-error-message">There was an error rendering the relationships diagram.</p>
726
+ <pre id="mini-erd-error-details" class="bg-light p-2 small mt-2"></pre>
727
+ </div>
728
+ </div>
729
+ <div id="debug-data" class="d-none m-3 border-top pt-3">
730
+ <details>
731
+ <summary>Debug Information</summary>
732
+ <div class="alert alert-info small">
733
+ <pre id="erd-data-debug" style="max-height: 100px; overflow: auto;">${JSON.stringify(data, null, 2)}</pre>
734
+ </div>
735
+ </details>
736
+ </div>
737
+ </div>
738
+ <div class="modal-footer">
739
+ <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
740
+ <a href="<%= dbviewer.entity_relationship_diagrams_path %>" class="btn btn-primary">View Full ERD</a>
741
+ </div>
742
+ `;
743
+
744
+ try {
745
+ const tables = data.tables || [];
746
+ const relationships = data.relationships || [];
747
+
748
+ // Validate data before proceeding
749
+ if (!Array.isArray(tables) || !Array.isArray(relationships)) {
750
+ showDiagramError('Invalid data format', 'The relationship data is not in the expected format.');
751
+ console.error('Invalid data format received:', data);
752
+ return;
753
+ }
754
+
755
+ console.log(`Found ${tables.length} tables and ${relationships.length} relationships`);
756
+
757
+ // Create the ER diagram definition in Mermaid syntax
758
+ let mermaidDefinition = 'erDiagram\n';
759
+
760
+ // Add tables to the diagram - ensure we have at least one table
761
+ if (tables.length === 0) {
762
+ mermaidDefinition += ` <%= @table_name.gsub(/[^\w]/, '_') %> {\n`;
763
+ mermaidDefinition += ` string id PK\n`;
764
+ mermaidDefinition += ` }\n`;
765
+ } else {
766
+ tables.forEach(function(table) {
767
+ const tableName = table.name;
768
+
769
+ if (!tableName) {
770
+ console.warn('Table with no name found:', table);
771
+ return; // Skip this table
772
+ }
773
+
774
+ // Clean table name for mermaid (remove special characters)
775
+ const cleanTableName = tableName.replace(/[^\w]/g, '_');
776
+
777
+ // Make the current table stand out with a different visualization
778
+ if (tableName === '<%= @table_name %>') {
779
+ mermaidDefinition += ` ${cleanTableName} {\n`;
780
+ mermaidDefinition += ` string id PK\n`;
781
+ mermaidDefinition += ` }\n`;
782
+ } else {
783
+ mermaidDefinition += ` ${cleanTableName} {\n`;
784
+ mermaidDefinition += ` string id\n`;
785
+ mermaidDefinition += ` }\n`;
786
+ }
787
+ });
788
+ }
789
+
790
+ // Add relationships
791
+ if (relationships && relationships.length > 0) {
792
+ relationships.forEach(function(rel) {
793
+ try {
794
+ // Ensure all required properties exist
795
+ if (!rel.from_table || !rel.to_table) {
796
+ console.error('Missing table in relationship:', rel);
797
+ return; // Skip this relationship
798
+ }
799
+
800
+ // Clean up table names for mermaid (remove special characters)
801
+ const fromTable = rel.from_table.replace(/[^\w]/g, '_');
802
+ const toTable = rel.to_table.replace(/[^\w]/g, '_');
803
+ const relationLabel = rel.from_column || '';
804
+
805
+ // Customize the display based on direction
806
+ mermaidDefinition += ` ${fromTable} }|--|| ${toTable} : "${relationLabel}"\n`;
807
+ } catch (err) {
808
+ console.error('Error processing relationship:', err, rel);
809
+ }
810
+ });
811
+ } else {
812
+ // Add a note if no relationships are found
813
+ mermaidDefinition += ' %% No relationships found for this table\n';
814
+ }
815
+
816
+ // Log the generated mermaid definition for debugging
817
+ console.log('Mermaid Definition:', mermaidDefinition);
818
+
819
+ // Hide the loading indicator first since render might take time
820
+ document.getElementById('mini-erd-loading').style.display = 'none';
821
+
822
+ // Render the diagram with Mermaid
823
+ mermaid.render('mini-erd-graph', mermaidDefinition)
824
+ .then(function(result) {
825
+ console.log('Mermaid rendering successful');
826
+
827
+ // Get the container
828
+ const container = document.getElementById('mini-erd-container');
829
+
830
+ // Insert the rendered SVG
831
+ container.innerHTML = result.svg;
832
+
833
+ // Style the SVG element for better fit
834
+ const svgElement = container.querySelector('svg');
835
+ if (svgElement) {
836
+ // Set size attributes for the SVG
837
+ svgElement.setAttribute('width', '100%');
838
+ svgElement.setAttribute('height', '100%');
839
+ svgElement.style.minHeight = '450px';
840
+ svgElement.style.width = '100%';
841
+ svgElement.style.height = '100%';
842
+
843
+ // Set viewBox if not present to enable proper scaling
844
+ if (!svgElement.getAttribute('viewBox')) {
845
+ const width = svgElement.getAttribute('width') || '100%';
846
+ const height = svgElement.getAttribute('height') || '100%';
847
+ svgElement.setAttribute('viewBox', `0 0 ${parseInt(width) || 1000} ${parseInt(height) || 800}`);
848
+ }
849
+ }
850
+
851
+ // Apply SVG-Pan-Zoom to make the diagram interactive
852
+ try {
853
+ const svgElement = container.querySelector('svg');
854
+ if (svgElement && typeof svgPanZoom !== 'undefined') {
855
+ // Make SVG take the full container width
856
+ svgElement.setAttribute('width', '100%');
857
+ svgElement.setAttribute('height', '100%');
858
+
859
+ // Initialize SVG Pan-Zoom
860
+ const panZoomInstance = svgPanZoom(svgElement, {
861
+ zoomEnabled: true,
862
+ controlIconsEnabled: true,
863
+ fit: true,
864
+ center: true,
865
+ minZoom: 0.5,
866
+ maxZoom: 2.5
867
+ });
868
+
869
+ // Store the panZoom instance for resize handling
870
+ container.panZoomInstance = panZoomInstance;
871
+
872
+ // Setup resize observer to maintain full size
873
+ const resizeObserver = new ResizeObserver(() => {
874
+ if (container.panZoomInstance) {
875
+ // Reset zoom and center when container is resized
876
+ container.panZoomInstance.resize();
877
+ container.panZoomInstance.fit();
878
+ container.panZoomInstance.center();
879
+ }
880
+ });
881
+
882
+ // Observe the container for size changes
883
+ resizeObserver.observe(container);
884
+
885
+ // Also handle manual resize on modal resize
886
+ miniErdModal.addEventListener('resize.bs.modal', function() {
887
+ if (container.panZoomInstance) {
888
+ setTimeout(() => {
889
+ container.panZoomInstance.resize();
890
+ container.panZoomInstance.fit();
891
+ container.panZoomInstance.center();
892
+ }, 100);
893
+ }
894
+ });
895
+ }
896
+ } catch (e) {
897
+ console.warn('Failed to initialize svg-pan-zoom:', e);
898
+ // Not critical, continue without pan-zoom
899
+ }
900
+
901
+ // Add highlighting for the current table
902
+ setTimeout(function() {
903
+ try {
904
+ const cleanTableName = '<%= @table_name %>'.replace(/[^\w]/g, '_');
905
+ const currentTableElement = container.querySelector(`[id*="${cleanTableName}"]`);
906
+ if (currentTableElement) {
907
+ const rect = currentTableElement.querySelector('rect');
908
+ if (rect) {
909
+ // Highlight the current table
910
+ rect.setAttribute('fill', document.documentElement.getAttribute('data-bs-theme') === 'dark' ? '#2c3034' : '#e2f0ff');
911
+ rect.setAttribute('stroke', document.documentElement.getAttribute('data-bs-theme') === 'dark' ? '#6ea8fe' : '#0d6efd');
912
+ rect.setAttribute('stroke-width', '2');
913
+ }
914
+ }
915
+ } catch (e) {
916
+ console.error('Error highlighting current table:', e);
917
+ }
918
+ }, 100);
919
+ })
920
+ .catch(function(error) {
921
+ console.error('Error rendering mini ERD:', error);
922
+ showDiagramError(
923
+ 'Error rendering diagram',
924
+ 'There was an error rendering the relationships diagram.',
925
+ error.message || 'Unknown error'
926
+ );
927
+
928
+ // Show debug data when there's an error
929
+ document.getElementById('debug-data').classList.remove('d-none');
930
+ });
931
+ } catch (error) {
932
+ console.error('Exception in renderMiniErd function:', error);
933
+ showDiagramError(
934
+ 'Exception generating diagram',
935
+ 'There was an exception processing the relationships diagram.',
936
+ error.message || 'Unknown error'
937
+ );
938
+
939
+ // Show debug data when there's an error
940
+ document.getElementById('debug-data').classList.remove('d-none');
941
+ }
942
+ }
943
+
944
+ // Function to show diagram error
945
+ function showDiagramError(title, message, details = '') {
946
+ const errorContainer = document.getElementById('mini-erd-error');
947
+ const errorMessage = document.getElementById('mini-erd-error-message');
948
+ const errorDetails = document.getElementById('mini-erd-error-details');
949
+ const loadingIndicator = document.getElementById('mini-erd-loading');
950
+
951
+ if (loadingIndicator) {
952
+ loadingIndicator.style.display = 'none';
953
+ }
954
+
955
+ if (errorContainer && errorMessage) {
956
+ // Set error message
957
+ errorMessage.textContent = message;
958
+
959
+ // Set error details if provided
960
+ if (details && errorDetails) {
961
+ errorDetails.textContent = details;
962
+ errorDetails.classList.remove('d-none');
963
+ } else if (errorDetails) {
964
+ errorDetails.classList.add('d-none');
965
+ }
966
+
967
+ // Show the error container
968
+ errorContainer.classList.remove('d-none');
969
+ }
970
+ }
971
+
972
+ // Handle modal shown event - adjust size after the modal is fully visible
973
+ miniErdModal.addEventListener('shown.bs.modal', function(event) {
974
+ // After modal is fully shown, resize the diagram to fit
975
+ const container = document.getElementById('mini-erd-container');
976
+ if (container && container.panZoomInstance) {
977
+ setTimeout(() => {
978
+ container.panZoomInstance.resize();
979
+ container.panZoomInstance.fit();
980
+ container.panZoomInstance.center();
981
+ }, 200); // Small delay to ensure modal is fully transitioned
982
+ }
983
+ });
984
+
985
+ // Handle modal close to reset state for future opens
986
+ miniErdModal.addEventListener('hidden.bs.modal', function(event) {
987
+ // Reset flags and cached data to ensure fresh fetch on next open
988
+ isModalLoaded = false;
989
+ erdData = null;
990
+ console.log('Modal closed, diagram data will be refetched on next open');
991
+ });
992
+ }
993
+
994
+ // Function to retry loading the Mini ERD
995
+ function retryLoadingMiniERD() {
996
+ console.log('Retrying loading of mini ERD');
997
+ const modalContent = document.getElementById('miniErdModalContent');
998
+
999
+ // Set loading state again
1000
+ modalContent.innerHTML = `
1001
+ <div class="modal-header">
1002
+ <h5 class="modal-title">Relationships for <%= @table_name %></h5>
1003
+ <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
1004
+ </div>
1005
+ <div class="modal-body p-0">
1006
+ <div id="mini-erd-container" class="w-100 d-flex justify-content-center align-items-center" style="min-height: 450px; height: 100%;">
1007
+ <div class="text-center">
1008
+ <div class="spinner-border text-primary mb-3" role="status">
1009
+ <span class="visually-hidden">Loading...</span>
1010
+ </div>
1011
+ <p>Retrying to load relationships diagram...</p>
1012
+ </div>
1013
+ </div>
1014
+ </div>
1015
+ `;
1016
+
1017
+ // Reset state to ensure fresh fetch
1018
+ isModalLoaded = false;
1019
+ erdData = null;
1020
+
1021
+ // Retry fetching data
1022
+ fetchErdData();
1023
+ }
270
1024
  });
271
1025
  </script>
272
1026
 
@@ -284,6 +1038,46 @@
284
1038
  font-size: 0.85rem;
285
1039
  }
286
1040
 
1041
+ /* Filter input group styling */
1042
+ .filter-input-group {
1043
+ display: flex;
1044
+ flex-direction: column;
1045
+ width: 100%;
1046
+ }
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 */
1056
+ }
1057
+
1058
+ .filter-input-group .operator-select option {
1059
+ font-size: 0.8rem;
1060
+ padding: 2px;
1061
+ }
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);
1067
+ }
1068
+
1069
+ .filter-input-group .column-filter {
1070
+ border-radius: 0 0 0.2rem 0.2rem;
1071
+ border-top: none;
1072
+ }
1073
+
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;
1079
+ }
1080
+
287
1081
  [data-bs-theme="dark"] .column-filters td {
288
1082
  background-color: rgba(255,255,255,0.05);
289
1083
  }
@@ -293,6 +1087,57 @@
293
1087
  color: rgba(255,255,255,0.9);
294
1088
  border-color: rgba(255,255,255,0.15);
295
1089
  }
1090
+
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);
1096
+ }
1097
+
1098
+ /* Mini ERD modal styling */
1099
+ #miniErdModal .modal-dialog {
1100
+ max-width: 90%;
1101
+ max-height: 90vh;
1102
+ height: 90vh;
1103
+ }
1104
+
1105
+ #miniErdModal .modal-content {
1106
+ height: 100%;
1107
+ }
1108
+
1109
+ #miniErdModal .modal-body {
1110
+ height: calc(100% - 130px); /* Account for header and footer */
1111
+ overflow: hidden; /* Prevent scrollbars within the modal body */
1112
+ }
1113
+
1114
+ #miniErdModal #mini-erd-container {
1115
+ height: 100%;
1116
+ width: 100%;
1117
+ }
1118
+
1119
+ #miniErdModal #mini-erd-container svg {
1120
+ width: 100%;
1121
+ height: 100%;
1122
+ max-height: unset;
1123
+ }
1124
+
1125
+ /* Foreign key link styling */
1126
+ .foreign-key-link {
1127
+ color: var(--bs-primary);
1128
+ position: relative;
1129
+ }
1130
+
1131
+ .foreign-key-link:hover {
1132
+ text-decoration: underline !important;
1133
+ }
1134
+
1135
+ .foreign-key-link + .bi-link-45deg {
1136
+ font-size: 0.75rem;
1137
+ margin-left: 0.25rem;
1138
+ position: relative;
1139
+ top: -1px;
1140
+ }
296
1141
  </style>
297
1142
 
298
1143
  <% if @timestamp_data.present? %>
@@ -371,6 +1216,39 @@
371
1216
  }
372
1217
  }
373
1218
  });
1219
+
1220
+ // Column sorting enhancement
1221
+ const sortableColumns = document.querySelectorAll('.sortable-column');
1222
+ sortableColumns.forEach(column => {
1223
+ const link = column.querySelector('.column-sort-link');
1224
+
1225
+ // Mouse over effects
1226
+ column.addEventListener('mouseenter', () => {
1227
+ const sortIcon = column.querySelector('.sort-icon');
1228
+ if (sortIcon && sortIcon.classList.contains('invisible')) {
1229
+ sortIcon.style.visibility = 'visible';
1230
+ sortIcon.style.opacity = '0.3';
1231
+ }
1232
+ });
1233
+
1234
+ column.addEventListener('mouseleave', () => {
1235
+ const sortIcon = column.querySelector('.sort-icon');
1236
+ if (sortIcon && sortIcon.classList.contains('invisible')) {
1237
+ sortIcon.style.visibility = 'hidden';
1238
+ sortIcon.style.opacity = '0';
1239
+ }
1240
+ });
1241
+
1242
+ // Keyboard accessibility
1243
+ if (link) {
1244
+ link.addEventListener('keydown', (e) => {
1245
+ if (e.key === 'Enter' || e.key === ' ') {
1246
+ e.preventDefault();
1247
+ link.click();
1248
+ }
1249
+ });
1250
+ }
1251
+ });
374
1252
  });
375
1253
  </script>
376
1254
  <% end %>