dbviewer 0.9.4.pre.alpha.1 → 0.9.4.pre.alpha.3

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: f79e82d83d69572cd7fa4331831e7fa7cc385f7d0655fec2ab9cee57bf5dcdcd
4
- data.tar.gz: 8a34270cfb5f9ede4b77d9b531a67cb1c6b3a022923b359080b4e171e725dadb
3
+ metadata.gz: 54a9324f879a9f3be0d71ab93e1ead14ca185cc3513fe44c86aec45927899ff8
4
+ data.tar.gz: 139bb5d6f6bc46ef2b16e7b9c77362d3231e709cb87f79f17f2304062dd3d3a3
5
5
  SHA512:
6
- metadata.gz: edfcfc4a7eb88659393cd59cac7af960339a686010aa57d6da0ccfcf000d9ad33ee18ebed955294ca741119bca0f1d63f2579682fab48f70e9ca6ec93bce936f
7
- data.tar.gz: b1e37a36cee23d2e04284c957e3f865afa24da128579b811f25cede3f2afcda9230abfaab332b48a7525ab190da5f53a270a7d0acc594de22324c9c624019f72
6
+ metadata.gz: '06767791b1d8df0bad7f1a7e98e6df81c063a50ca0bbba1f8a91186a89e093c56db8dd400e78f3fca9ae99700d1d85bc2a2351f76d9f6f6fa6692123b16b953f'
7
+ data.tar.gz: 8688791ec007fedf0e4b56ad2d65b067c5f86b77b918b322007364b2500073154a641105c3c369ff25ed35633d3a5cb055080922ebf50e380521a2eaf671ab7d
data/README.md CHANGED
@@ -14,7 +14,7 @@ It's designed for development, debugging, and database analysis, offering a clea
14
14
  - **Detailed Schema Information** - Column details, indexes, and constraints
15
15
  - **Entity Relationship Diagram (ERD)** - Interactive database schema visualization
16
16
  - **Data Browsing** - Paginated record viewing with search and filtering
17
- - **Data Creation** - Create new database records directly from the interface
17
+ - **Data Management** - Full CRUD operations (Create, Read, Update, Delete) for database records
18
18
  - **SQL Queries** - Safe SQL query execution with validation
19
19
  - **Multiple Database Connections** - Support for multiple database sources
20
20
  - **PII Data Masking** - Configurable masking for sensitive data
@@ -80,6 +80,27 @@ This is necessary because API-only Rails applications don't include the Flash mi
80
80
  - **ERD View** (`/dbviewer/entity_relationship_diagrams`): Interactive Entity Relationship Diagram of your database
81
81
  - **SQL Query Logs** (`/dbviewer/logs`): View and analyze logged SQL queries with performance metrics
82
82
 
83
+ ### Data Management Features
84
+
85
+ DBViewer provides a complete set of CRUD (Create, Read, Update, Delete) operations for your database records:
86
+
87
+ - **Create**: Add new records via a user-friendly modal form with field validation
88
+ - **Read**: Browse and view detailed record information with relationship navigation
89
+ - **Update**: Edit existing records through an intuitive form interface
90
+ - **Delete**: Remove records with confirmation dialogs to prevent accidental deletion
91
+
92
+ All data management features can be individually enabled or disabled through configuration options:
93
+
94
+ ```ruby
95
+ # config/initializers/dbviewer.rb
96
+ Dbviewer.configure do |config|
97
+ # Data management options
98
+ config.enable_data_export = false # Enable CSV export feature
99
+ config.enable_record_deletion = true # Enable record deletion feature
100
+ config.enable_record_editing = true # Enable record editing feature
101
+ end
102
+ ```
103
+
83
104
  ## ⚙️ Configuration Options
84
105
 
85
106
  You can configure DBViewer by using our generator to create an initializer in your application:
@@ -99,6 +120,8 @@ Dbviewer.configure do |config|
99
120
  config.cache_expiry = 300 # Cache expiration in seconds
100
121
  config.max_records = 10000 # Maximum records to return in any query
101
122
  config.enable_data_export = false # Whether to allow data exporting
123
+ config.enable_record_deletion = true # Whether to allow record deletion
124
+ config.enable_record_editing = true # Whether to allow record editing
102
125
  config.query_timeout = 30 # SQL query timeout in seconds
103
126
 
104
127
  # Query logging options
@@ -0,0 +1,171 @@
1
+ document.addEventListener("DOMContentLoaded", () => {
2
+ const tableName = document.getElementById("table_name")?.value;
3
+ const deleteConfirmModal = document.getElementById("deleteConfirmModal");
4
+ const recordDeleteForm = document.getElementById("recordDeleteForm");
5
+
6
+ // === TOASTIFY UTIL ===
7
+ const showToast = (message, type = "success") => {
8
+ const icons = {
9
+ success: "clipboard-check",
10
+ info: "info-circle",
11
+ danger: "exclamation-triangle",
12
+ warning: "exclamation-diamond",
13
+ };
14
+
15
+ Toastify({
16
+ text: `
17
+ <span class="toast-icon">
18
+ <i class="bi bi-${icons[type] || "info-circle"}"></i>
19
+ </span>
20
+ ${message}
21
+ `,
22
+ className: `toast-factory-bot toast-${type}`,
23
+ duration: 3000,
24
+ gravity: "bottom",
25
+ position: "right",
26
+ escapeMarkup: false,
27
+ style: {
28
+ animation:
29
+ "slideInRight 0.3s ease-out, slideOutRight 0.3s ease-out 2.7s",
30
+ background: type === "danger" ? "#dc3545" : undefined,
31
+ },
32
+ }).showToast();
33
+ };
34
+
35
+ // === SETUP DELETE MODAL ===
36
+ const setupDeleteConfirmModal = (recordData, pkName, pkValue) => {
37
+ if (!recordData || !pkValue)
38
+ return console.error("Missing record data for delete confirmation");
39
+
40
+ const infoDiv = document.getElementById("deleteRecordInfo");
41
+ const idInput = document.getElementById("deleteRecordId");
42
+
43
+ idInput.value = pkValue;
44
+ recordDeleteForm.action = `${
45
+ window.location.pathname
46
+ }/records/${encodeURIComponent(pkValue)}`;
47
+ infoDiv.innerHTML = `<div><strong>${pkName}:</strong> ${pkValue}</div>`;
48
+
49
+ const importantFields = [
50
+ "name",
51
+ "title",
52
+ "email",
53
+ "username",
54
+ "code",
55
+ "reference",
56
+ ];
57
+ let count = 0;
58
+
59
+ for (const key of Object.keys(recordData)) {
60
+ if (count >= 3) break;
61
+ const lowerKey = key.toLowerCase();
62
+ if (
63
+ key !== pkName &&
64
+ importantFields.some((f) => lowerKey.includes(f)) &&
65
+ recordData[key]
66
+ ) {
67
+ const fieldDiv = document.createElement("div");
68
+ fieldDiv.className = "mt-1";
69
+ fieldDiv.innerHTML = `<strong>${key}:</strong> ${recordData[key]}`;
70
+ infoDiv.appendChild(fieldDiv);
71
+ count++;
72
+ }
73
+ }
74
+ };
75
+
76
+ // === DELETE FROM DETAIL MODAL ===
77
+ const detailDeleteBtn = document.getElementById("recordDetailDeleteBtn");
78
+ if (detailDeleteBtn) {
79
+ detailDeleteBtn.addEventListener("click", () => {
80
+ const rows = document.querySelectorAll("#recordDetailTableBody tr");
81
+ const recordData = {};
82
+
83
+ rows.forEach((row) => {
84
+ const [keyCell, valueCell] = row.querySelectorAll("td");
85
+ if (keyCell && valueCell) {
86
+ recordData[keyCell.textContent.trim()] = valueCell.textContent.trim();
87
+ }
88
+ });
89
+
90
+ const pkName =
91
+ Object.keys(recordData).find((k) => k.toLowerCase() === "id") ||
92
+ Object.keys(recordData)[0];
93
+ const pkValue = recordData[pkName];
94
+
95
+ setupDeleteConfirmModal(recordData, pkName, pkValue);
96
+
97
+ bootstrap.Modal.getInstance(
98
+ document.getElementById("recordDetailModal")
99
+ )?.hide();
100
+ setTimeout(() => new bootstrap.Modal(deleteConfirmModal).show(), 500);
101
+ });
102
+ }
103
+
104
+ // === DELETE FROM TABLE ROW ===
105
+ document.querySelectorAll(".delete-record-btn").forEach((button) => {
106
+ button.addEventListener("click", () => {
107
+ const recordData = JSON.parse(button.dataset.recordData || "{}");
108
+ const pkName =
109
+ Object.keys(recordData).find((k) => k.toLowerCase() === "id") ||
110
+ Object.keys(recordData)[0];
111
+ const pkValue = recordData[pkName];
112
+ setupDeleteConfirmModal(recordData, pkName, pkValue);
113
+ });
114
+ });
115
+
116
+ // === FORM SUBMIT (DELETE) ===
117
+ if (recordDeleteForm) {
118
+ recordDeleteForm.addEventListener("submit", async (e) => {
119
+ e.preventDefault();
120
+
121
+ const form = e.target;
122
+ const submitButton = document.querySelector(
123
+ "#recordDeleteForm button[type='submit']"
124
+ );
125
+ const originalText = submitButton.innerHTML;
126
+ const csrfToken = document.querySelector(
127
+ 'meta[name="csrf-token"]'
128
+ )?.content;
129
+
130
+ disableSubmitButton(submitButton);
131
+
132
+ try {
133
+ const response = await fetch(form.action, {
134
+ method: "DELETE",
135
+ headers: {
136
+ "Content-Type": "application/json",
137
+ "X-CSRF-Token": csrfToken,
138
+ },
139
+ credentials: "same-origin",
140
+ });
141
+
142
+ const result = await response.json();
143
+
144
+ if (!response.ok) {
145
+ throw new Error(result?.error || "Failed to delete record");
146
+ }
147
+
148
+ showToast(result.message || "Record deleted successfully", "success");
149
+
150
+ const modal = bootstrap.Modal.getInstance(deleteConfirmModal);
151
+ modal.hide();
152
+
153
+ setTimeout(() => window.location.reload(), 1000);
154
+ } catch (error) {
155
+ console.error("Delete Error:", error);
156
+ showToast(error.message || "Unexpected error occurred", "danger");
157
+ } finally {
158
+ enableSubmitButton(submitButton, originalText);
159
+ }
160
+ });
161
+ }
162
+
163
+ // === BUTTON HELPERS ===
164
+ function disableSubmitButton(button) {
165
+ button.disabled = true;
166
+ }
167
+
168
+ function enableSubmitButton(button) {
169
+ button.disabled = false;
170
+ }
171
+ });
@@ -0,0 +1,208 @@
1
+ document.addEventListener("DOMContentLoaded", function () {
2
+ const tableName = document.getElementById("table_name")?.value;
3
+ if (!tableName) return;
4
+
5
+ // Handle edit button in record detail modal
6
+ const recordDetailEditBtn = document.getElementById("recordDetailEditBtn");
7
+ if (recordDetailEditBtn) {
8
+ recordDetailEditBtn.addEventListener("click", () => {
9
+ const recordData = extractRecordDataFromDetailModal();
10
+ // Use the primary key from the button's data attribute
11
+ const primaryKey =
12
+ recordDetailEditBtn.getAttribute("data-primary-key") || "id";
13
+ const primaryKeyValue = recordData[primaryKey];
14
+ loadEditForm(primaryKeyValue);
15
+ });
16
+ }
17
+
18
+ // Handle edit buttons in table rows
19
+ document.querySelectorAll(".edit-record-btn").forEach((button) => {
20
+ button.addEventListener("click", () => {
21
+ const recordData = JSON.parse(button.dataset.recordData || "{}");
22
+ const primaryKeyValue = extractPrimaryKeyValue(recordData);
23
+ loadEditForm(primaryKeyValue);
24
+ });
25
+ });
26
+
27
+ function extractRecordDataFromDetailModal() {
28
+ const tableBody = document.getElementById("recordDetailTableBody");
29
+ const rows = tableBody?.querySelectorAll("tr") || [];
30
+ const recordData = {};
31
+
32
+ rows.forEach((row) => {
33
+ const cells = row.querySelectorAll("td");
34
+ if (cells.length >= 2) {
35
+ const columnName = cells[0].textContent.trim();
36
+ const cellValue =
37
+ cells[1].textContent.trim() === "NULL"
38
+ ? ""
39
+ : cells[1].textContent.trim();
40
+ recordData[columnName] = cellValue;
41
+ }
42
+ });
43
+ return recordData;
44
+ }
45
+
46
+ function extractPrimaryKeyValue(recordData) {
47
+ // First try to get the primary key from the button data attribute
48
+ const clickedButton = document.activeElement;
49
+ if (
50
+ clickedButton &&
51
+ clickedButton.classList.contains("edit-record-btn") &&
52
+ clickedButton.dataset.primaryKey
53
+ ) {
54
+ const primaryKey = clickedButton.dataset.primaryKey;
55
+ return recordData[primaryKey];
56
+ }
57
+
58
+ // If not available, use the table-level metadata primary key from the hidden field
59
+ const primaryKeyMetaElement = document.getElementById("table_primary_key");
60
+ if (primaryKeyMetaElement && primaryKeyMetaElement.value) {
61
+ return recordData[primaryKeyMetaElement.value];
62
+ }
63
+
64
+ // Fallback: try to find 'id' or use the first key
65
+ const primaryKey =
66
+ Object.keys(recordData).find((key) => key.toLowerCase() === "id") ||
67
+ Object.keys(recordData)[0];
68
+ return recordData[primaryKey];
69
+ }
70
+
71
+ async function loadEditForm(recordId) {
72
+ const modal = document.getElementById("editRecordModal");
73
+ const modalBody = modal.querySelector(".modal-content");
74
+ const bsModal = new bootstrap.Modal(modal);
75
+
76
+ modalBody.innerHTML = `
77
+ <div class="modal-body text-center py-5">
78
+ <div class="spinner-border text-primary" role="status"></div>
79
+ <p class="mt-3">Loading edit form...</p>
80
+ </div>
81
+ `;
82
+
83
+ bsModal.show();
84
+
85
+ try {
86
+ const response = await fetch(
87
+ `${window.location.pathname}/records/${encodeURIComponent(
88
+ recordId
89
+ )}/edit`
90
+ );
91
+ if (!response.ok) throw new Error("Failed to load form");
92
+
93
+ const html = await response.text();
94
+ modalBody.innerHTML = html;
95
+
96
+ initializeEditFormElements();
97
+ document
98
+ .getElementById("editRecordForm")
99
+ ?.addEventListener("submit", handleEditFormSubmit);
100
+ } catch (error) {
101
+ modalBody.innerHTML = `
102
+ <div class="modal-header">
103
+ <h5 class="modal-title">Error</h5>
104
+ <button type="button" class="btn-close" data-bs-dismiss="modal"></button>
105
+ </div>
106
+ <div class="modal-body">
107
+ <div class="alert alert-danger">${error.message}</div>
108
+ </div>
109
+ <div class="modal-footer">
110
+ <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
111
+ </div>
112
+ `;
113
+ }
114
+ }
115
+
116
+ function initializeEditFormElements() {
117
+ if (typeof $.fn.select2 !== "undefined") {
118
+ $(".select2-dropdown").select2({
119
+ dropdownParent: $("#editRecordModal"),
120
+ theme: "bootstrap-5",
121
+ width: "100%",
122
+ });
123
+ }
124
+
125
+ if (typeof flatpickr !== "undefined") {
126
+ flatpickr(".datetime-picker", {
127
+ enableTime: true,
128
+ dateFormat: "Y-m-d H:i:S",
129
+ time_24hr: true,
130
+ wrap: true,
131
+ });
132
+
133
+ flatpickr(".date-picker", {
134
+ dateFormat: "Y-m-d",
135
+ wrap: true,
136
+ });
137
+ }
138
+ }
139
+
140
+ async function handleEditFormSubmit(event) {
141
+ event.preventDefault();
142
+
143
+ const form = event.target;
144
+ const submitButton = document.getElementById("updateRecordButton");
145
+ const originalText = submitButton?.innerHTML;
146
+ const formData = new FormData(form);
147
+ const csrfToken = document.querySelector(
148
+ 'meta[name="csrf-token"]'
149
+ )?.content;
150
+
151
+ disableSubmitButton(submitButton, "Updating...");
152
+
153
+ try {
154
+ const response = await fetch(form.action, {
155
+ method: form.method,
156
+ body: formData,
157
+ headers: { "X-CSRF-Token": csrfToken },
158
+ credentials: "same-origin",
159
+ });
160
+
161
+ const result = await response.json();
162
+
163
+ if (!response.ok)
164
+ throw new Error(
165
+ result?.messages?.join(", ") || "Failed to update record"
166
+ );
167
+
168
+ bootstrap.Modal.getInstance(
169
+ document.getElementById("editRecordModal")
170
+ )?.hide();
171
+ showToast(result.message || "Record updated successfully");
172
+ setTimeout(() => window.location.reload(), 1000);
173
+ } catch (error) {
174
+ showToast(error.message, "danger");
175
+ } finally {
176
+ enableSubmitButton(submitButton, originalText);
177
+ }
178
+ }
179
+
180
+ function disableSubmitButton(button, loadingText) {
181
+ if (!button) return;
182
+ button.disabled = true;
183
+ button.innerHTML = `<span class="spinner-border spinner-border-sm me-2"></span>${loadingText}`;
184
+ }
185
+
186
+ function enableSubmitButton(button, originalText) {
187
+ if (!button) return;
188
+ button.disabled = false;
189
+ button.innerHTML = originalText;
190
+ }
191
+
192
+ function showToast(message, type = "success") {
193
+ Toastify({
194
+ text: `<span class="toast-icon"><i class="bi bi-${
195
+ type === "success" ? "clipboard-check" : "exclamation-triangle"
196
+ }"></i></span> ${message}`,
197
+ className: "toast-factory-bot",
198
+ duration: 3000,
199
+ gravity: "bottom",
200
+ position: "right",
201
+ escapeMarkup: false,
202
+ style: {
203
+ animation: `slideInRight 0.3s ease-out, slideOutRight 0.3s ease-out 2.7s`,
204
+ background: type === "danger" ? "#dc3545" : undefined,
205
+ },
206
+ }).showToast();
207
+ }
208
+ });
@@ -3,7 +3,7 @@ module Dbviewer
3
3
  include Dbviewer::AccessControlValidation
4
4
 
5
5
  before_action :set_table_name, except: [ :index ]
6
- before_action :validate_table, only: [ :show, :query, :export_csv, :new_record, :create_record ]
6
+ before_action :validate_table, only: [ :show, :query, :export_csv, :new_record, :create_record, :destroy_record, :edit_record, :update_record ]
7
7
  before_action :set_query_filters, only: [ :show, :export_csv ]
8
8
  before_action :set_global_filters, only: [ :show, :export_csv ]
9
9
 
@@ -89,6 +89,67 @@ module Dbviewer
89
89
  disposition: "attachment; filename=#{filename}"
90
90
  end
91
91
 
92
+ def destroy_record
93
+ model_class = database_manager.get_model_for(@table_name)
94
+ primary_key = database_manager.primary_key(@table_name) || "id"
95
+ record = model_class.find_by(primary_key => params[:record_id])
96
+
97
+ if record.nil?
98
+ render json: { error: "Record not found" }, status: :not_found
99
+ return
100
+ end
101
+
102
+ begin
103
+ if record.destroy
104
+ render json: { message: "Record deleted successfully" }, status: :ok
105
+ else
106
+ render json: { errors: record.errors.full_messages, message: "Failed to delete record" }, status: :unprocessable_entity
107
+ end
108
+ rescue => e
109
+ Rails.logger.error("Error deleting record from #{@table_name}: #{e.message}")
110
+ render json: { error: "Failed to delete record: #{e.message}" }, status: :internal_server_error
111
+ end
112
+ end
113
+
114
+ def edit_record
115
+ model_class = database_manager.get_model_for(@table_name)
116
+ primary_key = database_manager.primary_key(@table_name) || "id"
117
+ @record = model_class.find_by(primary_key => params[:record_id])
118
+
119
+ if @record.nil?
120
+ render json: { error: "Record not found" }, status: :not_found
121
+ return
122
+ end
123
+
124
+ @table_columns = filter_accessible_columns(@table_name, database_manager.table_columns(@table_name))
125
+ @metadata = database_manager.table_metadata(@table_name)
126
+ @foreign_key_options = load_foreign_key_options(@metadata)
127
+
128
+ render layout: false
129
+ end
130
+
131
+ def update_record
132
+ model_class = database_manager.get_model_for(@table_name)
133
+ primary_key = database_manager.primary_key(@table_name) || "id"
134
+ record = model_class.find_by(primary_key => params[:record_id])
135
+
136
+ if record.nil?
137
+ render json: { error: "Record not found" }, status: :not_found
138
+ return
139
+ end
140
+
141
+ begin
142
+ if record.update(record_params)
143
+ render json: { message: "Record updated successfully" }
144
+ else
145
+ render json: { errors: record.errors, messages: record.errors.full_messages }, status: :unprocessable_entity
146
+ end
147
+ rescue => e
148
+ Rails.logger.error("Error updating record in #{@table_name}: #{e.message}")
149
+ render json: { error: "Failed to update record: #{e.message}" }, status: :internal_server_error
150
+ end
151
+ end
152
+
92
153
  private
93
154
 
94
155
  def record_params
@@ -137,8 +137,45 @@ module Dbviewer
137
137
  content_tag(:i, "", class: "bi bi-clipboard")
138
138
  end
139
139
 
140
- # Concatenate both buttons
141
- view_button + copy_factory_button
140
+ # Edit Record button (only if enabled in configuration)
141
+ edit_button = if Dbviewer.configuration.enable_record_editing
142
+ button_tag(
143
+ type: "button",
144
+ class: "btn btn-sm btn-outline-primary edit-record-btn",
145
+ title: "Edit Record",
146
+ data: {
147
+ record_data: data_attributes.to_json,
148
+ table_name: table_name,
149
+ primary_key: metadata && metadata[:primary_key] ? metadata[:primary_key] : "id"
150
+ }
151
+ ) do
152
+ content_tag(:i, "", class: "bi bi-pencil")
153
+ end
154
+ else
155
+ "".html_safe
156
+ end
157
+
158
+ # Delete Record button (only if enabled in configuration)
159
+ delete_button = if Dbviewer.configuration.enable_record_deletion
160
+ button_tag(
161
+ type: "button",
162
+ class: "btn btn-sm btn-outline-danger delete-record-btn",
163
+ title: "Delete Record",
164
+ data: {
165
+ bs_toggle: "modal",
166
+ bs_target: "#deleteConfirmModal",
167
+ record_data: data_attributes.to_json,
168
+ table_name: table_name
169
+ }
170
+ ) do
171
+ content_tag(:i, "", class: "bi bi-trash")
172
+ end
173
+ else
174
+ "".html_safe
175
+ end
176
+
177
+ # Concatenate all buttons
178
+ view_button + copy_factory_button + edit_button + delete_button
142
179
  end
143
180
  end
144
181
  end
@@ -0,0 +1,105 @@
1
+ <%
2
+ # Common variables:
3
+ # - form: The form builder
4
+ # - column: Current column hash with :name, :type, etc.
5
+ # - metadata: Table metadata with :foreign_keys etc.
6
+ # - foreign_key_options: Options for foreign key dropdowns
7
+ # - record: The record being edited (nil for new records)
8
+ # - is_edit_mode: Boolean flag indicating if this is an edit (vs create) operation
9
+
10
+ column_name = column[:name]
11
+ is_primary_key = metadata[:primary_key] == column_name
12
+
13
+ # For new records, skip auto-increment primary keys
14
+ skip_column = !is_edit_mode && is_primary_key # Skip primary key in creation mode
15
+
16
+ # Skip timestamps for both new and edit
17
+ return if skip_column || %w[created_at updated_at].include?(column_name)
18
+
19
+ # Get current value for edit mode
20
+ current_value = record ? record[column_name] : nil
21
+
22
+ # Common field properties
23
+ field_type = determine_field_type(column[:type])
24
+ foreign_key = metadata[:foreign_keys].find { |fk| fk[:column] == column_name }
25
+ field_id = "record_#{column_name}"
26
+ required = !column[:null]
27
+ disabled = is_edit_mode && is_primary_key # Disable editing of primary key in edit mode
28
+ %>
29
+
30
+ <div class="mb-3">
31
+ <%= form.label "record[#{column_name}]", column_name.humanize, class: "form-label" %>
32
+
33
+ <% if foreign_key && foreign_key_options[column_name].present? %>
34
+ <!-- Foreign key dropdown -->
35
+ <%= form.select "record[#{column_name}]",
36
+ options_for_select(foreign_key_options[column_name], current_value),
37
+ { include_blank: column[:null] ? "-- Select --" : false },
38
+ { class: "form-control select2-dropdown", id: field_id, disabled: disabled } %>
39
+
40
+ <% elsif field_type == :text || field_type == :text_area %>
41
+ <!-- Text area for long text fields -->
42
+ <%= form.text_area "record[#{column_name}]",
43
+ value: current_value,
44
+ class: "form-control",
45
+ id: field_id,
46
+ rows: 3,
47
+ required: required,
48
+ disabled: disabled %>
49
+
50
+ <% elsif field_type == :boolean || field_type == :check_box %>
51
+ <!-- Boolean field -->
52
+ <div class="form-check form-switch">
53
+ <%= form.check_box "record[#{column_name}]",
54
+ { class: "form-check-input",
55
+ id: field_id,
56
+ checked: current_value,
57
+ disabled: disabled },
58
+ "true", "false" %>
59
+ </div>
60
+
61
+ <% elsif field_type == :datetime %>
62
+ <!-- Date time picker -->
63
+ <div class="input-group flatpickr">
64
+ <%= form.text_field "record[#{column_name}]",
65
+ value: current_value,
66
+ class: "form-control datetime-picker",
67
+ id: field_id,
68
+ required: required,
69
+ data: { input: "" },
70
+ disabled: disabled %>
71
+ <button type="button" class="input-group-text" data-toggle><i class="bi bi-calendar-event"></i></button>
72
+ </div>
73
+
74
+ <% elsif field_type == :date %>
75
+ <!-- Date picker -->
76
+ <div class="input-group flatpickr">
77
+ <%= form.text_field "record[#{column_name}]",
78
+ value: current_value,
79
+ class: "form-control date-picker",
80
+ id: field_id,
81
+ required: required,
82
+ data: { input: "" },
83
+ disabled: disabled %>
84
+ <button type="button" class="input-group-text" data-toggle><i class="bi bi-calendar-event"></i></button>
85
+ </div>
86
+
87
+ <% else %>
88
+ <!-- Default text input -->
89
+ <%= form.text_field "record[#{column_name}]",
90
+ value: current_value,
91
+ class: "form-control",
92
+ id: field_id,
93
+ required: required,
94
+ disabled: disabled %>
95
+ <% end %>
96
+
97
+ <% if disabled %>
98
+ <!-- Add a hidden field to preserve the primary key value -->
99
+ <%= form.hidden_field "record[#{column_name}]", value: current_value %>
100
+ <% end %>
101
+
102
+ <% if !is_edit_mode && column[:default].present? && column[:default] != "NULL" %>
103
+ <div class="form-text text-muted">Default: <%= column[:default] %></div>
104
+ <% end %>
105
+ </div>
@@ -0,0 +1,42 @@
1
+ <% if @errors.present? %>
2
+ <div class="alert alert-danger">
3
+ <ul class="mb-0">
4
+ <% @errors.full_messages.each do |message| %>
5
+ <li><%= message %></li>
6
+ <% end %>
7
+ </ul>
8
+ </div>
9
+ <% end %>
10
+
11
+ <div class="modal-header">
12
+ <h5 class="modal-title" id="editRecordModalLabel">
13
+ <i class="bi bi-pencil-square me-1"></i> Edit <%= @table_name.humanize.titleize %> Record
14
+ </h5>
15
+ <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
16
+ </div>
17
+
18
+ <div class="modal-body">
19
+ <% primary_key = @metadata[:primary_key] || "id" %>
20
+ <% primary_key_value = @record[primary_key] %>
21
+
22
+ <%= form_with url: update_record_table_path(@table_name, record_id: primary_key_value), method: :patch, id: "editRecordForm" do |form| %>
23
+ <% @table_columns.each do |column| %>
24
+ <%= render partial: 'dbviewer/tables/record_form_fields',
25
+ locals: {
26
+ form: form,
27
+ column: column,
28
+ metadata: @metadata,
29
+ foreign_key_options: @foreign_key_options,
30
+ record: @record,
31
+ is_edit_mode: true
32
+ } %>
33
+ <% end %>
34
+ <% end %>
35
+ </div>
36
+
37
+ <div class="modal-footer">
38
+ <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
39
+ <button type="submit" form="editRecordForm" class="btn btn-primary" id="updateRecordButton">
40
+ <i class="bi bi-check-lg me-1"></i>Update Record
41
+ </button>
42
+ </div>
@@ -18,46 +18,15 @@
18
18
  <div class="modal-body">
19
19
  <%= form_with url: create_record_table_path(@table_name), method: :post, id: "newRecordForm" do |form| %>
20
20
  <% @table_columns.each do |column| %>
21
- <%
22
- column_name = column[:name]
23
- is_primary_key = @metadata[:primary_key] == column_name
24
- skip_column = is_primary_key && %w[id].include?(column_name.downcase)
25
-
26
- # Skip auto-increment primary keys and timestamps
27
- next if skip_column || %w[created_at updated_at].include?(column_name)
28
- %>
29
-
30
- <div class="mb-3">
31
- <%
32
- # Handle different field types
33
- field_type = determine_field_type(column[:type])
34
- foreign_key = @metadata[:foreign_keys].find { |fk| fk[:column] == column_name }
35
- field_id = "record_#{column_name}"
36
- required = !column[:null]
37
- %>
38
-
39
- <%= form.label "record[#{column_name}]", column_name.humanize, class: "form-label" %>
40
-
41
- <% if foreign_key && @foreign_key_options[column_name].present? %>
42
- <%= form.select "record[#{column_name}]",
43
- options_for_select(@foreign_key_options[column_name]),
44
- { include_blank: column[:null] ? "-- Select --" : false },
45
- { class: "form-select form-control select2-dropdown" }
46
- %>
47
- <% elsif field_type == :check_box %>
48
- <div class="form-check">
49
- <%= form.check_box "record[#{column_name}]", class: "form-check-input", id: field_id %>
50
- </div>
51
- <% elsif field_type == :text_area %>
52
- <%= form.text_area "record[#{column_name}]", class: "form-control", id: field_id, rows: 3, required: required %>
53
- <% else %>
54
- <%= form.send(field_type, "record[#{column_name}]", class: "form-control", id: field_id, required: required) %>
55
- <% end %>
56
-
57
- <% if column[:default].present? && column[:default] != "NULL" %>
58
- <div class="form-text text-muted">Default: <%= column[:default] %></div>
59
- <% end %>
60
- </div>
21
+ <%= render partial: 'dbviewer/tables/record_form_fields',
22
+ locals: {
23
+ form: form,
24
+ column: column,
25
+ metadata: @metadata,
26
+ foreign_key_options: @foreign_key_options,
27
+ record: nil,
28
+ is_edit_mode: false
29
+ } %>
61
30
  <% end %>
62
31
  <% end %>
63
32
  </div>
@@ -14,6 +14,10 @@
14
14
  <%= stylesheet_link_tag "dbviewer/table", "data-turbo-track": "reload" %>
15
15
  <%= javascript_include_tag "dbviewer/table", "data-turbo-track": "reload" %>
16
16
  <%= javascript_include_tag "dbviewer/record_creation", "data-turbo-track": "reload" %>
17
+ <%= javascript_include_tag "dbviewer/record_deletion", "data-turbo-track": "reload" %>
18
+ <% if Dbviewer.configuration.enable_record_editing %>
19
+ <%= javascript_include_tag "dbviewer/record_editing", "data-turbo-track": "reload" %>
20
+ <% end %>
17
21
  <% end %>
18
22
 
19
23
  <% content_for :sidebar_active do %>active<% end %>
@@ -192,6 +196,16 @@
192
196
  </div>
193
197
  <div class="modal-footer">
194
198
  <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
199
+ <% if Dbviewer.configuration.enable_record_editing %>
200
+ <button type="button" class="btn btn-primary" id="recordDetailEditBtn" data-record-id="" data-primary-key="<%= @metadata[:primary_key] || 'id' %>">
201
+ <i class="bi bi-pencil me-1"></i>Edit Record
202
+ </button>
203
+ <% end %>
204
+ <% if Dbviewer.configuration.enable_record_deletion %>
205
+ <button type="button" class="btn btn-danger" id="recordDetailDeleteBtn" data-record-id="">
206
+ <i class="bi bi-trash me-1"></i>Delete Record
207
+ </button>
208
+ <% end %>
195
209
  </div>
196
210
  </div>
197
211
  </div>
@@ -382,5 +396,57 @@
382
396
  </div>
383
397
  </div>
384
398
 
399
+ <% if Dbviewer.configuration.enable_record_deletion %>
400
+ <!-- Delete Confirmation Modal -->
401
+ <div class="modal fade" id="deleteConfirmModal" tabindex="-1" aria-labelledby="deleteConfirmModalLabel" aria-hidden="true">
402
+ <div class="modal-dialog">
403
+ <div class="modal-content">
404
+ <div class="modal-header bg-danger text-white">
405
+ <h5 class="modal-title" id="deleteConfirmModalLabel">
406
+ <i class="bi bi-exclamation-triangle-fill me-2"></i>Confirm Deletion
407
+ </h5>
408
+ <button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
409
+ </div>
410
+ <div class="modal-body">
411
+ <p>Are you sure you want to delete this record from <strong><%= @table_name %></strong>?</p>
412
+ <div class="alert alert-warning">
413
+ <i class="bi bi-exclamation-triangle-fill me-2"></i>
414
+ <strong>Warning:</strong> This action cannot be undone.
415
+ </div>
416
+
417
+ <!-- Primary key info will be inserted here -->
418
+ <div id="deleteRecordInfo" class="mt-3 mb-2 p-2 border-start border-4 border-danger ps-3">
419
+ <!-- Record info will be displayed here -->
420
+ </div>
421
+ </div>
422
+ <div class="modal-footer">
423
+ <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
424
+ <%= form_with(url: "#", method: :delete, id: "recordDeleteForm") do |form| %>
425
+ <input type="hidden" id="deleteRecordId" name="record_id" value="">
426
+ <button type="submit" class="btn btn-danger">
427
+ <i class="bi bi-trash me-1"></i>Delete Record
428
+ </button>
429
+ <% end %>
430
+ </div>
431
+ </div>
432
+ </div>
433
+ </div>
434
+ <% end %>
435
+
436
+ <!-- Edit Record Modal -->
437
+ <% if Dbviewer.configuration.enable_record_editing %>
438
+ <div id="editRecordModal" class="modal fade" tabindex="-1" aria-labelledby="editRecordModalLabel" aria-hidden="true">
439
+ <div class="modal-dialog modal-lg">
440
+ <div class="modal-content">
441
+ <!-- Content will be loaded via AJAX -->
442
+ </div>
443
+ </div>
444
+ </div>
445
+ <% end %>
446
+
447
+ <!-- Toast container for notifications -->
448
+ <div id="toast-container" class="toast-container position-fixed bottom-0 end-0 p-3"></div>
449
+
385
450
  <input type="hidden" id="mini_erd_table_path" name="mini_erd_table_path" value="<%= dbviewer.mini_erd_api_table_path(@table_name, format: :json) %>">
386
451
  <input type="hidden" id="table_name" name="table_name" value="<%= @table_name %>">
452
+ <input type="hidden" id="table_primary_key" name="table_primary_key" value="<%= @metadata[:primary_key] || 'id' %>">
data/config/routes.rb CHANGED
@@ -6,6 +6,9 @@ Dbviewer::Engine.routes.draw do
6
6
  get "export_csv"
7
7
  get "new_record"
8
8
  post "create_record"
9
+ delete "records/:record_id", to: "tables#destroy_record", as: :destroy_record
10
+ get "records/:record_id/edit", to: "tables#edit_record", as: :edit_record
11
+ patch "records/:record_id", to: "tables#update_record", as: :update_record
9
12
  end
10
13
  end
11
14
 
@@ -117,6 +117,12 @@ module Dbviewer
117
117
  # Maximum number of security events to keep in memory
118
118
  attr_accessor :max_security_events
119
119
 
120
+ # Enable or disable record deletion functionality
121
+ attr_accessor :enable_record_deletion
122
+
123
+ # Enable or disable record editing functionality
124
+ attr_accessor :enable_record_editing
125
+
120
126
  def initialize
121
127
  @per_page_options = [ 10, 20, 50, 100 ]
122
128
  @default_per_page = 20
@@ -124,6 +130,8 @@ module Dbviewer
124
130
  @max_query_length = 10000
125
131
  @cache_expiry = 300
126
132
  @enable_data_export = false
133
+ @enable_record_deletion = true
134
+ @enable_record_editing = true
127
135
  @query_timeout = 30
128
136
  @query_logging_mode = :memory
129
137
  @query_log_path = "log/dbviewer.log"
@@ -1,3 +1,3 @@
1
1
  module Dbviewer
2
- VERSION = "0.9.4-alpha.1"
2
+ VERSION = "0.9.4-alpha.3"
3
3
  end
@@ -13,6 +13,9 @@ Dbviewer.configure do |config|
13
13
  config.query_log_path = "log/dbviewer.log" # Path for query log file when in :file mode
14
14
  config.max_memory_queries = 1000 # Maximum number of queries to store in memory
15
15
 
16
+ # Data Management options
17
+ config.enable_record_deletion = true # Whether to allow record deletion functionality
18
+
16
19
  # Authentication options (Basic Auth)
17
20
  # config.admin_credentials = {
18
21
  # username: "admin",
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: dbviewer
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.9.4.pre.alpha.1
4
+ version: 0.9.4.pre.alpha.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Wailan Tirajoh
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2025-07-05 00:00:00.000000000 Z
11
+ date: 2025-07-06 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -87,6 +87,8 @@ files:
87
87
  - app/assets/javascripts/dbviewer/layout.js
88
88
  - app/assets/javascripts/dbviewer/query.js
89
89
  - app/assets/javascripts/dbviewer/record_creation.js
90
+ - app/assets/javascripts/dbviewer/record_deletion.js
91
+ - app/assets/javascripts/dbviewer/record_editing.js
90
92
  - app/assets/javascripts/dbviewer/sidebar.js
91
93
  - app/assets/javascripts/dbviewer/table.js
92
94
  - app/assets/javascripts/dbviewer/utility.js
@@ -136,7 +138,9 @@ files:
136
138
  - app/views/dbviewer/home/index.html.erb
137
139
  - app/views/dbviewer/logs/index.html.erb
138
140
  - app/views/dbviewer/shared/_tables_sidebar.html.erb
141
+ - app/views/dbviewer/tables/_record_form_fields.html.erb
139
142
  - app/views/dbviewer/tables/_table_structure.html.erb
143
+ - app/views/dbviewer/tables/edit_record.html.erb
140
144
  - app/views/dbviewer/tables/index.html.erb
141
145
  - app/views/dbviewer/tables/new_record.html.erb
142
146
  - app/views/dbviewer/tables/query.html.erb