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
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: cbd62dc61d3960414f9f2496634d3241eadf3270c424006190d3cf2a6f909a15
|
4
|
+
data.tar.gz: 83365d3965efc2b431b61c085ebc7b15d6e23564c785e584ede10f015efd143d
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 6587f9a6cbe948f32bf45a36ba60c40ea06109e534c3fffdb78bb6a727e80c6676b7cb8027df1ff9876bb4eaab37688e565e10372f321277f377980667f53923
|
7
|
+
data.tar.gz: f93fe7e8f7cb1721f65e17a01288fa17be0bd2fdee25b278f157cfe56a85e9fe6a943cd8d5f58bd8a1a5cc604fb5e5b84b541d2a76e0f1663e86a248ff6ef3d9
|
@@ -1,3 +1,5 @@
|
|
1
|
+
require "csv"
|
2
|
+
|
1
3
|
module Dbviewer
|
2
4
|
module DatabaseOperations
|
3
5
|
extend ActiveSupport::Concern
|
@@ -373,39 +375,19 @@ module Dbviewer
|
|
373
375
|
|
374
376
|
# Export table data to CSV
|
375
377
|
def export_table_to_csv(table_name, query_params = nil, include_headers = true)
|
376
|
-
|
377
|
-
|
378
|
-
begin
|
379
|
-
if query_params.is_a?(Dbviewer::TableQueryParams)
|
380
|
-
# Use the query params object directly
|
381
|
-
records = database_manager.query_operations.table_records(table_name, query_params)
|
382
|
-
else
|
383
|
-
# Legacy support for the old method signature
|
384
|
-
limit = query_params.is_a?(Numeric) ? query_params : 10000
|
385
|
-
records = database_manager.table_records(
|
386
|
-
table_name,
|
387
|
-
1, # First page
|
388
|
-
nil, # Default sorting
|
389
|
-
"asc",
|
390
|
-
limit # Limit number of records
|
391
|
-
)
|
392
|
-
end
|
378
|
+
records = database_manager.query_operations.table_records(table_name, query_params)
|
393
379
|
|
394
|
-
|
395
|
-
|
396
|
-
|
380
|
+
csv_data = CSV.generate do |csv|
|
381
|
+
# Add headers if requested
|
382
|
+
csv << records.columns if include_headers
|
397
383
|
|
398
|
-
|
399
|
-
|
400
|
-
|
401
|
-
end
|
384
|
+
# Add rows
|
385
|
+
records.rows.each do |row|
|
386
|
+
csv << row.map { |cell| format_csv_value(cell) }
|
402
387
|
end
|
403
|
-
|
404
|
-
csv_data
|
405
|
-
rescue => e
|
406
|
-
Rails.logger.error("CSV Export error for table #{table_name}: #{e.message}")
|
407
|
-
raise "Error exporting to CSV: #{e.message}"
|
408
388
|
end
|
389
|
+
|
390
|
+
csv_data
|
409
391
|
end
|
410
392
|
|
411
393
|
private
|
@@ -18,23 +18,6 @@ module Dbviewer
|
|
18
18
|
end
|
19
19
|
end
|
20
20
|
|
21
|
-
# Set pagination parameters from request or defaults
|
22
|
-
def set_pagination_params
|
23
|
-
@current_page = [ 1, params[:page].to_i ].max
|
24
|
-
@per_page = params[:per_page] ? params[:per_page].to_i : self.class.default_per_page
|
25
|
-
@per_page = self.class.default_per_page unless self.class.per_page_options.include?(@per_page)
|
26
|
-
end
|
27
|
-
|
28
|
-
# Set sorting parameters from request or defaults
|
29
|
-
def set_sorting_params
|
30
|
-
@order_by = params[:order_by].presence ||
|
31
|
-
database_manager.primary_key(@table_name).presence ||
|
32
|
-
(@columns.first ? @columns.first[:name] : nil)
|
33
|
-
|
34
|
-
@order_direction = params[:order_direction].upcase if params[:order_direction].present?
|
35
|
-
@order_direction = "ASC" unless self.class::VALID_SORT_DIRECTIONS.include?(@order_direction)
|
36
|
-
end
|
37
|
-
|
38
21
|
def fetch_total_count(table_name, query_params)
|
39
22
|
if query_params.column_filters.present? && query_params.column_filters.values.any?(&:present?)
|
40
23
|
fetch_filtered_record_count(table_name, query_params.column_filters)
|
@@ -3,22 +3,14 @@ module Dbviewer
|
|
3
3
|
include Dbviewer::PaginationConcern
|
4
4
|
|
5
5
|
before_action :set_table_name, except: [ :index ]
|
6
|
+
before_action :set_query_filters, only: [ :show, :export_csv ]
|
7
|
+
before_action :set_global_filters, only: [ :show, :export_csv ]
|
6
8
|
|
7
9
|
def index
|
8
10
|
@tables = fetch_tables_with_stats(include_record_counts: true)
|
9
11
|
end
|
10
12
|
|
11
13
|
def show
|
12
|
-
set_pagination_params
|
13
|
-
set_sorting_params
|
14
|
-
|
15
|
-
# Get column filters from params first
|
16
|
-
@column_filters = params[:column_filters].presence ? params[:column_filters].to_enum.to_h : {}
|
17
|
-
|
18
|
-
# Then apply global creation filters (this will modify @column_filters)
|
19
|
-
set_global_filters
|
20
|
-
|
21
|
-
# Now create the query params with the combined filters
|
22
14
|
query_params = Dbviewer::TableQueryParams.new(
|
23
15
|
page: @current_page,
|
24
16
|
per_page: @per_page,
|
@@ -33,7 +25,7 @@ module Dbviewer
|
|
33
25
|
@metadata = fetch_table_metadata(@table_name)
|
34
26
|
|
35
27
|
if @records.nil?
|
36
|
-
column_names = @columns.map { |
|
28
|
+
column_names = @columns.map { |column| column[:name] }
|
37
29
|
@records = ActiveRecord::Result.new(column_names, [])
|
38
30
|
end
|
39
31
|
|
@@ -57,28 +49,11 @@ module Dbviewer
|
|
57
49
|
end
|
58
50
|
|
59
51
|
def mini_erd
|
60
|
-
|
61
|
-
@erd_data = fetch_mini_erd_for_table(@table_name)
|
62
|
-
|
63
|
-
if @erd_data[:error].present?
|
64
|
-
Rails.logger.error("Mini ERD error: #{@erd_data[:error]}")
|
65
|
-
end
|
66
|
-
|
67
|
-
respond_to do |format|
|
68
|
-
format.json { render json: @erd_data }
|
69
|
-
format.html { render layout: false }
|
70
|
-
end
|
71
|
-
rescue => e
|
72
|
-
Rails.logger.error("Error generating Mini ERD: #{e.message}")
|
73
|
-
Rails.logger.error(e.backtrace.join("\n"))
|
74
|
-
|
75
|
-
@error_message = e.message
|
76
|
-
@erd_data = { tables: [], relationships: [], error: @error_message }
|
52
|
+
@erd_data = fetch_mini_erd_for_table(@table_name)
|
77
53
|
|
78
|
-
|
79
|
-
|
80
|
-
|
81
|
-
end
|
54
|
+
respond_to do |format|
|
55
|
+
format.json { render json: @erd_data }
|
56
|
+
format.html { render layout: false }
|
82
57
|
end
|
83
58
|
end
|
84
59
|
|
@@ -87,9 +62,6 @@ module Dbviewer
|
|
87
62
|
@columns = fetch_table_columns(@table_name)
|
88
63
|
@tables = fetch_tables_with_stats # Fetch tables for sidebar
|
89
64
|
|
90
|
-
# Set active table for sidebar highlighting
|
91
|
-
@active_table = @table_name
|
92
|
-
|
93
65
|
prepare_query
|
94
66
|
execute_query
|
95
67
|
|
@@ -103,36 +75,19 @@ module Dbviewer
|
|
103
75
|
return
|
104
76
|
end
|
105
77
|
|
106
|
-
limit = (params[:limit] || 10000).to_i
|
107
78
|
include_headers = params[:include_headers] != "0"
|
108
|
-
|
109
|
-
# Apply global creation filters
|
110
|
-
set_global_filters
|
111
|
-
|
112
|
-
# Create query parameters for export
|
113
79
|
query_params = Dbviewer::TableQueryParams.new(
|
114
|
-
page:
|
115
|
-
per_page: limit,
|
116
|
-
order_by:
|
117
|
-
direction:
|
80
|
+
page: @current_page,
|
81
|
+
per_page: (params[:limit] || 10000).to_i,
|
82
|
+
order_by: @order_by,
|
83
|
+
direction: @order_direction,
|
118
84
|
column_filters: @column_filters.reject { |_, v| v.blank? }
|
119
85
|
)
|
120
|
-
|
121
|
-
# Get filtered data for export
|
122
86
|
csv_data = export_table_to_csv(@table_name, query_params, include_headers)
|
123
87
|
|
124
|
-
# Set filename with timestamp for uniqueness
|
125
88
|
timestamp = Time.now.strftime("%Y%m%d%H%M%S")
|
89
|
+
filename = "#{@table_name}_#{timestamp}.csv"
|
126
90
|
|
127
|
-
# Add filter info to filename if filters are applied
|
128
|
-
filename_suffix = ""
|
129
|
-
if @creation_filter_start.present? || @creation_filter_end.present?
|
130
|
-
filename_suffix = "_filtered"
|
131
|
-
end
|
132
|
-
|
133
|
-
filename = "#{@table_name}#{filename_suffix}_#{timestamp}.csv"
|
134
|
-
|
135
|
-
# Send data as file
|
136
91
|
send_data csv_data,
|
137
92
|
type: "text/csv; charset=utf-8; header=present",
|
138
93
|
disposition: "attachment; filename=#{filename}"
|
@@ -142,7 +97,20 @@ module Dbviewer
|
|
142
97
|
|
143
98
|
def set_table_name
|
144
99
|
@table_name = params[:id]
|
145
|
-
end
|
100
|
+
end
|
101
|
+
|
102
|
+
def set_query_filters
|
103
|
+
@current_page = [ 1, params[:page].to_i ].max
|
104
|
+
@per_page = params[:per_page] ? params[:per_page].to_i : self.class.default_per_page
|
105
|
+
@per_page = self.class.default_per_page unless self.class.per_page_options.include?(@per_page)
|
106
|
+
@order_by = params[:order_by].presence ||
|
107
|
+
database_manager.primary_key(@table_name).presence ||
|
108
|
+
(@columns.first ? @columns.first[:name] : nil)
|
109
|
+
@order_direction = params[:order_direction].upcase if params[:order_direction].present?
|
110
|
+
@order_direction = "ASC" unless self.class::VALID_SORT_DIRECTIONS.include?(@order_direction)
|
111
|
+
@column_filters = params[:column_filters].presence ? params[:column_filters].to_enum.to_h : {}
|
112
|
+
end
|
113
|
+
|
146
114
|
def set_global_filters
|
147
115
|
# Store creation filter datetimes in session to persist between table navigation
|
148
116
|
if params[:creation_filter_start].present?
|
@@ -168,31 +136,25 @@ module Dbviewer
|
|
168
136
|
|
169
137
|
# Apply creation filters to column_filters if the table has a created_at column
|
170
138
|
if has_timestamp_column?(@table_name) && (@creation_filter_start.present? || @creation_filter_end.present?)
|
171
|
-
# Clear any existing created_at filters
|
172
|
-
@column_filters.delete(
|
173
|
-
|
174
|
-
|
175
|
-
|
176
|
-
|
177
|
-
|
178
|
-
|
179
|
-
|
180
|
-
|
181
|
-
|
182
|
-
|
183
|
-
|
184
|
-
|
185
|
-
|
186
|
-
|
187
|
-
|
188
|
-
|
189
|
-
|
190
|
-
@column_filters["created_at_operator"] = "lte" # Less than or equal
|
191
|
-
Rails.logger.info("[DBViewer] Setting creation filter end: #{@creation_filter_end}")
|
192
|
-
end
|
193
|
-
else
|
194
|
-
if @creation_filter_start.present? || @creation_filter_end.present?
|
195
|
-
Rails.logger.info("[DBViewer] Creation filter present but table #{@table_name} has no created_at column")
|
139
|
+
# Clear any existing created_at filters
|
140
|
+
%w[created_at created_at_operator created_at_end].each { |key| @column_filters.delete(key) }
|
141
|
+
|
142
|
+
case
|
143
|
+
when @creation_filter_start.present? && @creation_filter_end.present?
|
144
|
+
@column_filters.merge!({
|
145
|
+
"created_at" => @creation_filter_start,
|
146
|
+
"created_at_end" => @creation_filter_end
|
147
|
+
})
|
148
|
+
when @creation_filter_start.present?
|
149
|
+
@column_filters.merge!({
|
150
|
+
"created_at" => @creation_filter_start,
|
151
|
+
"created_at_operator" => "gte"
|
152
|
+
})
|
153
|
+
when @creation_filter_end.present?
|
154
|
+
@column_filters.merge!({
|
155
|
+
"created_at" => @creation_filter_end,
|
156
|
+
"created_at_operator" => "lte"
|
157
|
+
})
|
196
158
|
end
|
197
159
|
end
|
198
160
|
end
|
@@ -14,6 +14,110 @@ module Dbviewer
|
|
14
14
|
@database_manager ||= ::Dbviewer::DatabaseManager.new
|
15
15
|
end
|
16
16
|
|
17
|
+
# Extract column type from columns info
|
18
|
+
def column_type_from_info(column_name, columns)
|
19
|
+
return nil unless columns.present?
|
20
|
+
|
21
|
+
column_info = columns.find { |c| c[:name].to_s == column_name.to_s }
|
22
|
+
column_info ? column_info[:type].to_s.downcase : nil
|
23
|
+
end
|
24
|
+
|
25
|
+
# Determine default operator based on column type
|
26
|
+
def default_operator_for_column_type(column_type)
|
27
|
+
if column_type && column_type =~ /char|text|string|uuid|enum/i
|
28
|
+
"contains"
|
29
|
+
else
|
30
|
+
"eq"
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
# Generate operator options based on column type
|
35
|
+
def operator_options_for_column_type(column_type)
|
36
|
+
if column_type && (column_type =~ /datetime/ || column_type =~ /^date$/ || column_type =~ /^time$/)
|
37
|
+
# Date/Time operators
|
38
|
+
[
|
39
|
+
[ "=", "eq" ],
|
40
|
+
[ "≠", "neq" ],
|
41
|
+
[ "<", "lt" ],
|
42
|
+
[ ">", "gt" ],
|
43
|
+
[ "≤", "lte" ],
|
44
|
+
[ "≥", "gte" ]
|
45
|
+
]
|
46
|
+
elsif column_type && column_type =~ /int|float|decimal|double|number|numeric|real|money|bigint|smallint|tinyint|mediumint|bit/i
|
47
|
+
# Numeric operators
|
48
|
+
[
|
49
|
+
[ "=", "eq" ],
|
50
|
+
[ "≠", "neq" ],
|
51
|
+
[ "<", "lt" ],
|
52
|
+
[ ">", "gt" ],
|
53
|
+
[ "≤", "lte" ],
|
54
|
+
[ "≥", "gte" ]
|
55
|
+
]
|
56
|
+
else
|
57
|
+
# Text operators
|
58
|
+
[
|
59
|
+
[ "contains", "contains" ],
|
60
|
+
[ "not contains", "not_contains" ],
|
61
|
+
[ "=", "eq" ],
|
62
|
+
[ "≠", "neq" ],
|
63
|
+
[ "starts with", "starts_with" ],
|
64
|
+
[ "ends with", "ends_with" ]
|
65
|
+
]
|
66
|
+
end
|
67
|
+
end
|
68
|
+
|
69
|
+
# Render column filter input based on column type
|
70
|
+
def render_column_filter_input(form, column_name, column_type, column_filters)
|
71
|
+
if column_type && column_type =~ /datetime/
|
72
|
+
form.datetime_local_field("column_filters[#{column_name}]",
|
73
|
+
value: column_filters[column_name],
|
74
|
+
class: "form-control form-control-sm column-filter rounded-0",
|
75
|
+
data: { column: column_name })
|
76
|
+
elsif column_type && column_type =~ /^date$/
|
77
|
+
form.date_field("column_filters[#{column_name}]",
|
78
|
+
value: column_filters[column_name],
|
79
|
+
class: "form-control form-control-sm column-filter rounded-0",
|
80
|
+
data: { column: column_name })
|
81
|
+
elsif column_type && column_type =~ /^time$/
|
82
|
+
form.time_field("column_filters[#{column_name}]",
|
83
|
+
value: column_filters[column_name],
|
84
|
+
class: "form-control form-control-sm column-filter rounded-0",
|
85
|
+
data: { column: column_name })
|
86
|
+
else
|
87
|
+
form.text_field("column_filters[#{column_name}]",
|
88
|
+
value: column_filters[column_name],
|
89
|
+
placeholder: "",
|
90
|
+
class: "form-control form-control-sm column-filter rounded-0",
|
91
|
+
data: { column: column_name })
|
92
|
+
end
|
93
|
+
end
|
94
|
+
|
95
|
+
# Render operator select for column filter
|
96
|
+
def render_operator_select(form, column_name, column_type, column_filters)
|
97
|
+
# Get previously selected operator or default
|
98
|
+
default_operator = default_operator_for_column_type(column_type)
|
99
|
+
selected_operator = column_filters["#{column_name}_operator"]
|
100
|
+
selected_operator = default_operator if selected_operator.nil? || selected_operator == "default"
|
101
|
+
|
102
|
+
# Get appropriate options
|
103
|
+
operator_options = operator_options_for_column_type(column_type)
|
104
|
+
|
105
|
+
form.select("column_filters[#{column_name}_operator]",
|
106
|
+
options_for_select(operator_options, selected_operator),
|
107
|
+
{ include_blank: false },
|
108
|
+
{ class: "form-select form-select-sm operator-select" })
|
109
|
+
end
|
110
|
+
|
111
|
+
# Render complete filter input group for a column
|
112
|
+
def render_column_filter(form, column_name, columns, column_filters)
|
113
|
+
column_type = column_type_from_info(column_name, columns)
|
114
|
+
|
115
|
+
content_tag(:div, class: "filter-input-group") do
|
116
|
+
render_operator_select(form, column_name, column_type, column_filters) +
|
117
|
+
render_column_filter_input(form, column_name, column_type, column_filters)
|
118
|
+
end
|
119
|
+
end
|
120
|
+
|
17
121
|
def format_cell_value(value)
|
18
122
|
return "NULL" if value.nil?
|
19
123
|
return value.to_s.truncate(100) unless value.is_a?(String)
|
@@ -41,6 +145,91 @@ module Dbviewer
|
|
41
145
|
end
|
42
146
|
end
|
43
147
|
|
148
|
+
# Common parameters for pagination and filtering
|
149
|
+
def common_params(options = {})
|
150
|
+
params = {
|
151
|
+
order_by: @order_by,
|
152
|
+
order_direction: @order_direction,
|
153
|
+
per_page: @per_page,
|
154
|
+
column_filters: @column_filters
|
155
|
+
}.merge(options)
|
156
|
+
|
157
|
+
# Add creation filters if they exist
|
158
|
+
params[:creation_filter_start] = @creation_filter_start if @creation_filter_start.present?
|
159
|
+
params[:creation_filter_end] = @creation_filter_end if @creation_filter_end.present?
|
160
|
+
|
161
|
+
params
|
162
|
+
end
|
163
|
+
|
164
|
+
# Render pagination UI
|
165
|
+
def render_pagination(table_name, current_page, total_pages, params = {})
|
166
|
+
return unless total_pages && total_pages > 1
|
167
|
+
|
168
|
+
content_tag(:nav, 'aria-label': "Page navigation") do
|
169
|
+
content_tag(:ul, class: "pagination justify-content-center") do
|
170
|
+
prev_link = content_tag(:li, class: "page-item #{current_page == 1 ? 'disabled' : ''}") do
|
171
|
+
link_to "«", table_path(table_name, params.merge(page: [ current_page - 1, 1 ].max)), class: "page-link"
|
172
|
+
end
|
173
|
+
|
174
|
+
# Calculate page range to display
|
175
|
+
start_page = [ 1, current_page - 2 ].max
|
176
|
+
end_page = [ start_page + 4, total_pages ].min
|
177
|
+
start_page = [ 1, end_page - 4 ].max
|
178
|
+
|
179
|
+
# Generate page links
|
180
|
+
page_links = (start_page..end_page).map do |page_num|
|
181
|
+
content_tag(:li, class: "page-item #{page_num == current_page ? 'active' : ''}") do
|
182
|
+
link_to page_num, table_path(table_name, params.merge(page: page_num)), class: "page-link"
|
183
|
+
end
|
184
|
+
end.join.html_safe
|
185
|
+
|
186
|
+
next_link = content_tag(:li, class: "page-item #{current_page == total_pages ? 'disabled' : ''}") do
|
187
|
+
link_to "»", table_path(table_name, params.merge(page: [ current_page + 1, total_pages ].min)), class: "page-link"
|
188
|
+
end
|
189
|
+
|
190
|
+
prev_link + page_links + next_link
|
191
|
+
end
|
192
|
+
end
|
193
|
+
end
|
194
|
+
|
195
|
+
# Generate URL parameters for per-page dropdown
|
196
|
+
def per_page_url_params(table_name)
|
197
|
+
# Start with the dynamic part for the select element
|
198
|
+
url_params = "per_page=' + this.value + '&page=1"
|
199
|
+
|
200
|
+
# Add all other common parameters except per_page and page which we already set
|
201
|
+
params = common_params.except(:per_page, :page)
|
202
|
+
|
203
|
+
# Convert the params hash to URL parameters
|
204
|
+
params.each do |key, value|
|
205
|
+
if key == :column_filters && value.is_a?(Hash) && value.reject { |_, v| v.blank? }.any?
|
206
|
+
value.reject { |_, v| v.blank? }.each do |filter_key, filter_value|
|
207
|
+
url_params += "&column_filters[#{filter_key}]=#{CGI.escape(filter_value.to_s)}"
|
208
|
+
end
|
209
|
+
elsif value.present?
|
210
|
+
url_params += "&#{key}=#{CGI.escape(value.to_s)}"
|
211
|
+
end
|
212
|
+
end
|
213
|
+
|
214
|
+
url_params
|
215
|
+
end
|
216
|
+
|
217
|
+
# Render time grouping links
|
218
|
+
def time_grouping_links(table_name, current_grouping)
|
219
|
+
params = common_params
|
220
|
+
|
221
|
+
content_tag(:div, class: "btn-group btn-group-sm", role: "group", 'aria-label': "Time grouping") do
|
222
|
+
[
|
223
|
+
link_to("Hourly", table_path(table_name, params.merge(time_group: "hourly")),
|
224
|
+
class: "btn btn-outline-primary #{current_grouping == 'hourly' ? 'active' : ''}"),
|
225
|
+
link_to("Daily", table_path(table_name, params.merge(time_group: "daily")),
|
226
|
+
class: "btn btn-outline-primary #{current_grouping == 'daily' ? 'active' : ''}"),
|
227
|
+
link_to("Weekly", table_path(table_name, params.merge(time_group: "weekly")),
|
228
|
+
class: "btn btn-outline-primary #{current_grouping == 'weekly' ? 'active' : ''}")
|
229
|
+
].join.html_safe
|
230
|
+
end
|
231
|
+
end
|
232
|
+
|
44
233
|
# Dark mode helper methods
|
45
234
|
|
46
235
|
# Returns the theme toggle icon based on the current theme
|
@@ -159,23 +348,8 @@ module Dbviewer
|
|
159
348
|
"none"
|
160
349
|
end
|
161
350
|
|
162
|
-
#
|
163
|
-
sort_params =
|
164
|
-
order_by: column_name,
|
165
|
-
order_direction: sort_direction,
|
166
|
-
page: current_page,
|
167
|
-
per_page: per_page,
|
168
|
-
column_filters: column_filters
|
169
|
-
}
|
170
|
-
|
171
|
-
# Add creation filter parameters if they're in the controller
|
172
|
-
if defined?(@creation_filter_start) && @creation_filter_start.present?
|
173
|
-
sort_params[:creation_filter_start] = @creation_filter_start
|
174
|
-
end
|
175
|
-
|
176
|
-
if defined?(@creation_filter_end) && @creation_filter_end.present?
|
177
|
-
sort_params[:creation_filter_end] = @creation_filter_end
|
178
|
-
end
|
351
|
+
# Use common_params helper to build parameters
|
352
|
+
sort_params = common_params(order_by: column_name, order_direction: sort_direction)
|
179
353
|
|
180
354
|
link_to table_path(table_name, sort_params),
|
181
355
|
class: "d-flex align-items-center text-decoration-none text-reset column-sort-link",
|
@@ -187,5 +361,127 @@ module Dbviewer
|
|
187
361
|
content_tag(:span, sort_icon(column_name, current_order_by, current_direction), class: "sort-icon-container")
|
188
362
|
end
|
189
363
|
end
|
364
|
+
|
365
|
+
# Render a complete table header row with sortable columns
|
366
|
+
def render_sortable_header_row(records, order_by, order_direction, table_name, current_page, per_page, column_filters)
|
367
|
+
return content_tag(:tr) { content_tag(:th, "No columns available") } unless records&.columns
|
368
|
+
|
369
|
+
content_tag(:tr) do
|
370
|
+
# Start with action column header (sticky first column)
|
371
|
+
headers = [
|
372
|
+
content_tag(:th, class: "px-3 py-2 text-center action-column action-column-header", width: "60px", rowspan: 2) do
|
373
|
+
content_tag(:span, "Actions")
|
374
|
+
end
|
375
|
+
]
|
376
|
+
|
377
|
+
# Add all data columns
|
378
|
+
headers += records.columns.map do |column_name|
|
379
|
+
is_sorted = order_by == column_name
|
380
|
+
content_tag(:th, class: "px-3 py-2 sortable-column #{is_sorted ? 'sorted' : ''}") do
|
381
|
+
sortable_column_header(column_name, order_by, order_direction, table_name, current_page, per_page, column_filters)
|
382
|
+
end
|
383
|
+
end
|
384
|
+
|
385
|
+
headers.join.html_safe
|
386
|
+
end
|
387
|
+
end
|
388
|
+
|
389
|
+
# Render the column filters row
|
390
|
+
def render_column_filters_row(form, records, columns, column_filters)
|
391
|
+
return content_tag(:tr) { content_tag(:th, "") } unless records&.columns
|
392
|
+
|
393
|
+
content_tag(:tr, class: "column-filters") do
|
394
|
+
filters = records.columns.map do |column_name|
|
395
|
+
content_tag(:th, class: "p-0") do
|
396
|
+
render_column_filter(form, column_name, columns, column_filters)
|
397
|
+
end
|
398
|
+
end
|
399
|
+
|
400
|
+
filters.join.html_safe
|
401
|
+
end
|
402
|
+
end
|
403
|
+
|
404
|
+
# Render a cell that may include a foreign key link
|
405
|
+
def render_table_cell(cell, column_name, metadata)
|
406
|
+
cell_value = format_cell_value(cell)
|
407
|
+
foreign_key = metadata && metadata[:foreign_keys] ?
|
408
|
+
metadata[:foreign_keys].find { |fk| fk[:column] == column_name } :
|
409
|
+
nil
|
410
|
+
|
411
|
+
if foreign_key && !cell.nil?
|
412
|
+
fk_params = { column_filters: { foreign_key[:primary_key] => cell } }
|
413
|
+
fk_params = fk_params.merge(common_params.except(:column_filters))
|
414
|
+
|
415
|
+
content_tag(:td, title: "#{cell_value} (Click to view referenced record)") do
|
416
|
+
link_to(cell_value, table_path(foreign_key[:to_table], fk_params),
|
417
|
+
class: "text-decoration-none foreign-key-link") +
|
418
|
+
content_tag(:i, "", class: "bi bi-link-45deg text-muted small")
|
419
|
+
end
|
420
|
+
else
|
421
|
+
content_tag(:td, cell_value, title: cell_value)
|
422
|
+
end
|
423
|
+
end
|
424
|
+
|
425
|
+
# Render a table row with cells
|
426
|
+
def render_table_row(row, records, metadata)
|
427
|
+
content_tag(:tr) do
|
428
|
+
# Start with action column (sticky first column)
|
429
|
+
cells = [ render_action_cell(row, records.columns, metadata) ]
|
430
|
+
|
431
|
+
# Add all data cells
|
432
|
+
cells += row.each_with_index.map do |cell, cell_index|
|
433
|
+
column_name = records.columns[cell_index]
|
434
|
+
render_table_cell(cell, column_name, metadata)
|
435
|
+
end
|
436
|
+
|
437
|
+
cells.join.html_safe
|
438
|
+
end
|
439
|
+
end
|
440
|
+
|
441
|
+
# Render the entire table body with rows
|
442
|
+
def render_table_body(records, metadata)
|
443
|
+
if records.nil? || records.rows.nil? || records.empty?
|
444
|
+
content_tag(:tbody) do
|
445
|
+
content_tag(:tr) do
|
446
|
+
# Adding +1 to account for the action column
|
447
|
+
total_columns = records&.columns&.size.to_i + 1
|
448
|
+
content_tag(:td, "No records found or table is empty.", colspan: total_columns, class: "text-center")
|
449
|
+
end
|
450
|
+
end
|
451
|
+
else
|
452
|
+
content_tag(:tbody) do
|
453
|
+
records.rows.map do |row|
|
454
|
+
render_table_row(row, records, metadata)
|
455
|
+
end.join.html_safe
|
456
|
+
end
|
457
|
+
end
|
458
|
+
end
|
459
|
+
|
460
|
+
# Render action buttons for a record
|
461
|
+
def render_action_cell(row_data, columns, metadata = nil)
|
462
|
+
data_attributes = {}
|
463
|
+
|
464
|
+
# Create a hash of column_name: value pairs for data attributes
|
465
|
+
columns.each_with_index do |column_name, index|
|
466
|
+
data_attributes[column_name] = row_data[index].to_s
|
467
|
+
end
|
468
|
+
|
469
|
+
content_tag(:td, class: "text-center action-column") do
|
470
|
+
button_tag(
|
471
|
+
type: "button",
|
472
|
+
class: "btn btn-sm btn-primary view-record-btn",
|
473
|
+
title: "View Record Details",
|
474
|
+
data: {
|
475
|
+
bs_toggle: "modal",
|
476
|
+
bs_target: "#recordDetailModal",
|
477
|
+
record_data: data_attributes.to_json,
|
478
|
+
foreign_keys: metadata && metadata[:foreign_keys] ? metadata[:foreign_keys].to_json : "[]",
|
479
|
+
reverse_foreign_keys: metadata && metadata[:reverse_foreign_keys] ? metadata[:reverse_foreign_keys].to_json : "[]"
|
480
|
+
}
|
481
|
+
) do
|
482
|
+
content_tag(:i, "", class: "bi bi-eye")
|
483
|
+
end
|
484
|
+
end
|
485
|
+
end
|
190
486
|
end
|
191
487
|
end
|