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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6bf35f21728dd04ca42715af60f89f48b1bfdb4e7111b00f75e801a33bc17c47
4
- data.tar.gz: 2d54d185da3255486a7f5b92ad44430916ae8af7ac2e0674c09424d14ef08a6f
3
+ metadata.gz: cbd62dc61d3960414f9f2496634d3241eadf3270c424006190d3cf2a6f909a15
4
+ data.tar.gz: 83365d3965efc2b431b61c085ebc7b15d6e23564c785e584ede10f015efd143d
5
5
  SHA512:
6
- metadata.gz: 22e06d88b9cfcb981f5760a2a1d9fdf790f10e83012e4637a42468be02c68a22cb0fd2aeb43f05119ad34d251aec67fc9fc84223adb1eeff79a2ba922982a49f
7
- data.tar.gz: 893e168a8c704c21a8fed6ccb2291952e1b028f0182a63d71dd3be7ec7d659599921640c7174d6ed888cdcab134171b015ab1af194f90ddeafd09c3f3ac3d6e6
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
- require "csv"
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
- csv_data = CSV.generate do |csv|
395
- # Add headers if requested
396
- csv << records.columns if include_headers
380
+ csv_data = CSV.generate do |csv|
381
+ # Add headers if requested
382
+ csv << records.columns if include_headers
397
383
 
398
- # Add rows
399
- records.rows.each do |row|
400
- csv << row.map { |cell| format_csv_value(cell) }
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)
@@ -2,6 +2,9 @@ module Dbviewer
2
2
  class HomeController < ApplicationController
3
3
  def index
4
4
  @analytics = fetch_database_analytics
5
+ if Dbviewer.configuration.enable_query_logging
6
+ @recent_queries = Dbviewer::Logger.instance.recent_queries(limit: 10)
7
+ end
5
8
  end
6
9
 
7
10
  private
@@ -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 { |c| c[:name] }
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
- begin
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
- respond_to do |format|
79
- format.json { render json: { error: @error_message }, status: :internal_server_error }
80
- format.html { render layout: false }
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: 1,
115
- per_page: limit,
116
- order_by: nil,
117
- direction: "asc",
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 # Handle global creation datetime filters across tables
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 to avoid conflicts
172
- @column_filters.delete("created_at")
173
- @column_filters.delete("created_at_operator")
174
- @column_filters.delete("created_at_end")
175
-
176
- if @creation_filter_start.present? && @creation_filter_end.present?
177
- # If both start and end are present, set up a range filter
178
- @column_filters["created_at"] = @creation_filter_start
179
- @column_filters["created_at_end"] = @creation_filter_end
180
- # No need for operator when using start and end together
181
- Rails.logger.info("[DBViewer] Setting creation filter range: #{@creation_filter_start} to #{@creation_filter_end}")
182
- elsif @creation_filter_start.present?
183
- # Only start date is present
184
- @column_filters["created_at"] = @creation_filter_start
185
- @column_filters["created_at_operator"] = "gte" # Greater than or equal
186
- Rails.logger.info("[DBViewer] Setting creation filter start: #{@creation_filter_start}")
187
- elsif @creation_filter_end.present?
188
- # Only end date is present
189
- @column_filters["created_at"] = @creation_filter_end
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
- # Build parameters for the sort link
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
@@ -1,9 +1,5 @@
1
1
  <% content_for :title, "Entity Relationship Diagram" %>
2
2
 
3
- <% content_for :sidebar do %>
4
- <%= render 'dbviewer/shared/sidebar' %>
5
- <% end %>
6
-
7
3
  <div class="container-fluid h-100">
8
4
  <div class="row h-100">
9
5
  <div class="col-md-12 p-0">