dbviewer 0.3.15 → 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.
@@ -7,6 +7,92 @@
7
7
  <script src="https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js"></script>
8
8
  <!-- SVG-Pan-Zoom for interactive diagram navigation -->
9
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
+
10
96
  <script>
11
97
  // Initialize mermaid when document is ready
12
98
  document.addEventListener('DOMContentLoaded', function() {
@@ -37,10 +123,11 @@
37
123
 
38
124
  <% content_for :sidebar do %>
39
125
  <%= render 'dbviewer/shared/sidebar' %>
40
- <% end %>
41
-
126
+ <% end %>
42
127
  <div class="d-flex justify-content-between align-items-center mb-4">
43
- <h1>Table: <%= @table_name %></h1>
128
+ <div>
129
+ <h1>Table: <%= @table_name %></h1>
130
+ </div>
44
131
  <div class="d-flex gap-2">
45
132
  <button type="button" class="btn btn-secondary" data-bs-toggle="modal" data-bs-target="#miniErdModal">
46
133
  <i class="bi bi-diagram-3 me-1"></i> View Relationships
@@ -119,16 +206,50 @@
119
206
  <!-- Records Section -->
120
207
  <div class="dbviewer-card card mb-4">
121
208
  <div class="card-header d-flex justify-content-between align-items-center">
122
- <h5 class="mb-0"><i class="bi bi-table me-2"></i>Records</h5>
123
- <div class="d-flex align-items-center">
124
- <div class="me-3">
125
- <label for="per-page-select" class="me-2">Per page:</label>
126
- <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("&") : "" %>'">
127
- <% Dbviewer::TablesController.per_page_options.each do |option| %>
128
- <option value="<%= option %>" <%= 'selected' if @per_page == option %>><%= option %></option>
129
- <% end %>
130
- </select>
131
- </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>
132
253
  <span class="badge bg-secondary">Total: <%= @total_count %> records</span>
133
254
  <% active_filters = @column_filters.reject { |_, v| v.blank? }.size %>
134
255
  <% if active_filters > 0 %>
@@ -150,8 +271,9 @@
150
271
  <tr>
151
272
  <% if @records && @records.columns %>
152
273
  <% @records.columns.each do |column_name| %>
153
- <th class="pe-4">
154
- <%= 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) %>
155
277
  </th>
156
278
  <% end %>
157
279
  <% else %>
@@ -162,11 +284,87 @@
162
284
  <% if @records && @records.columns %>
163
285
  <% @records.columns.each do |column_name| %>
164
286
  <th class="p-0">
165
- <%= form.text_field "column_filters[#{column_name}]",
166
- value: @column_filters[column_name],
167
- placeholder: "",
168
- class: "form-control form-control-sm column-filter rounded-0",
169
- data: { column: column_name } %>
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>
170
368
  </th>
171
369
  <% end %>
172
370
  <% else %>
@@ -194,10 +392,17 @@
194
392
  nil
195
393
  %>
196
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
+ %>
197
403
  <td title="<%= cell_value %> (Click to view referenced record)">
198
404
  <%= link_to cell_value,
199
- table_path(foreign_key[:to_table],
200
- column_filters: { foreign_key[:primary_key] => cell }),
405
+ table_path(foreign_key[:to_table], fk_params),
201
406
  class: "text-decoration-none foreign-key-link" %>
202
407
  <i class="bi bi-link-45deg text-muted small"></i>
203
408
  </td>
@@ -214,10 +419,23 @@
214
419
  </div>
215
420
 
216
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
+ %>
217
435
  <nav aria-label="Page navigation">
218
436
  <ul class="pagination justify-content-center">
219
437
  <li class="page-item <%= 'disabled' if @current_page == 1 %>">
220
- <%= 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' %>
221
439
  </li>
222
440
 
223
441
  <% start_page = [1, @current_page - 2].max %>
@@ -226,12 +444,12 @@
226
444
 
227
445
  <% (start_page..end_page).each do |page_num| %>
228
446
  <li class="page-item <%= 'active' if page_num == @current_page %>">
229
- <%= 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' %>
230
448
  </li>
231
449
  <% end %>
232
450
 
233
451
  <li class="page-item <%= 'disabled' if @current_page == @total_pages %>">
234
- <%= 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' %>
235
453
  </li>
236
454
  </ul>
237
455
  </nav>
@@ -249,10 +467,23 @@
249
467
  <div class="card-header d-flex justify-content-between align-items-center">
250
468
  <h5 class="mb-0"><i class="bi bi-graph-up me-2"></i>Record Creation Timeline</h5>
251
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
+ %>
252
483
  <div class="btn-group btn-group-sm" role="group" aria-label="Time grouping">
253
- <%= 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' : ''}" %>
254
- <%= 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' : ''}" %>
255
- <%= 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' : ''}" %>
256
487
  </div>
257
488
  </div>
258
489
  </div>
@@ -301,6 +532,7 @@
301
532
  document.addEventListener('DOMContentLoaded', function() {
302
533
  // Column filter functionality
303
534
  const columnFilters = document.querySelectorAll('.column-filter');
535
+ const operatorSelects = document.querySelectorAll('.operator-select');
304
536
  const filterForm = document.getElementById('column-filters-form');
305
537
 
306
538
  // Add debounce function to reduce form submissions
@@ -323,7 +555,18 @@
323
555
 
324
556
  // Add event listeners to all filter inputs
325
557
  columnFilters.forEach(function(filter) {
558
+ // For text fields use input event
326
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);
327
570
  });
328
571
 
329
572
  // Add clear button functionality if there are any filters applied
@@ -343,7 +586,17 @@
343
586
  paginationContainer.insertAdjacentHTML('afterend', clearButton.outerHTML);
344
587
 
345
588
  document.getElementById('clear-filters').addEventListener('click', function() {
589
+ // Reset all input values
346
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
+
347
600
  submitForm();
348
601
  });
349
602
  }
@@ -785,6 +1038,46 @@
785
1038
  font-size: 0.85rem;
786
1039
  }
787
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
+
788
1081
  [data-bs-theme="dark"] .column-filters td {
789
1082
  background-color: rgba(255,255,255,0.05);
790
1083
  }
@@ -795,6 +1088,13 @@
795
1088
  border-color: rgba(255,255,255,0.15);
796
1089
  }
797
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
+
798
1098
  /* Mini ERD modal styling */
799
1099
  #miniErdModal .modal-dialog {
800
1100
  max-width: 90%;
@@ -916,6 +1216,39 @@
916
1216
  }
917
1217
  }
918
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
+ });
919
1252
  });
920
1253
  </script>
921
1254
  <% end %>