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.
- checksums.yaml +4 -4
- data/app/controllers/concerns/dbviewer/database_operations.rb +11 -29
- data/app/controllers/concerns/dbviewer/pagination_concern.rb +0 -17
- data/app/controllers/dbviewer/home_controller.rb +3 -0
- data/app/controllers/dbviewer/tables_controller.rb +45 -83
- data/app/helpers/dbviewer/application_helper.rb +313 -17
- data/app/views/dbviewer/entity_relationship_diagrams/index.html.erb +0 -4
- data/app/views/dbviewer/home/index.html.erb +34 -95
- data/app/views/dbviewer/logs/index.html.erb +0 -4
- data/app/views/dbviewer/tables/index.html.erb +0 -4
- data/app/views/dbviewer/tables/query.html.erb +0 -4
- data/app/views/dbviewer/tables/show.html.erb +516 -309
- data/app/views/layouts/dbviewer/application.html.erb +2 -3
- data/lib/dbviewer/table_metadata_manager.rb +33 -1
- data/lib/dbviewer/version.rb +1 -1
- metadata +2 -2
- /data/app/views/{dbviewer → layouts/dbviewer}/shared/_sidebar.html.erb +0 -0
@@ -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
|
-
|
272
|
-
|
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
|
-
|
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
|
-
|
1035
|
-
|
1036
|
-
|
1037
|
-
|
1038
|
-
|
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
|
-
/*
|
1042
|
-
|
1043
|
-
|
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
|
-
|
1049
|
-
|
1050
|
-
|
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
|
-
.
|
1059
|
-
|
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"] .
|
1064
|
-
background-color:
|
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
|
-
.
|
1070
|
-
|
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
|
-
|
1075
|
-
|
1076
|
-
|
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
|
-
|
1082
|
-
|
1199
|
+
.view-record-btn:hover {
|
1200
|
+
opacity: 0.85;
|
1201
|
+
transform: translateY(-1px);
|
1083
1202
|
}
|
1084
1203
|
|
1085
|
-
|
1086
|
-
|
1087
|
-
|
1088
|
-
border-
|
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
|
-
|
1092
|
-
|
1093
|
-
|
1094
|
-
|
1095
|
-
|
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
|
-
/*
|
1099
|
-
#
|
1100
|
-
|
1101
|
-
|
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
|
-
#
|
1106
|
-
|
1224
|
+
#relationshipsSection h6 {
|
1225
|
+
color: var(--bs-emphasis-color);
|
1226
|
+
margin-bottom: 1rem;
|
1107
1227
|
}
|
1108
1228
|
|
1109
|
-
#
|
1110
|
-
|
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
|
-
|
1115
|
-
|
1116
|
-
|
1233
|
+
.relationships-table .btn-outline-primary {
|
1234
|
+
font-size: 0.75rem;
|
1235
|
+
padding: 0.25rem 0.5rem;
|
1117
1236
|
}
|
1118
1237
|
|
1119
|
-
|
1120
|
-
|
1121
|
-
|
1122
|
-
|
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
|
-
|
1126
|
-
|
1127
|
-
color: var(--bs-
|
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
|
-
|
1136
|
-
|
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 %>
|