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 +4 -4
- data/README.md +24 -1
- data/app/assets/javascripts/dbviewer/record_deletion.js +171 -0
- data/app/assets/javascripts/dbviewer/record_editing.js +208 -0
- data/app/controllers/dbviewer/tables_controller.rb +62 -1
- data/app/helpers/dbviewer/datatable_ui_table_helper.rb +39 -2
- data/app/views/dbviewer/tables/_record_form_fields.html.erb +105 -0
- data/app/views/dbviewer/tables/edit_record.html.erb +42 -0
- data/app/views/dbviewer/tables/new_record.html.erb +9 -40
- data/app/views/dbviewer/tables/show.html.erb +66 -0
- data/config/routes.rb +3 -0
- data/lib/dbviewer/configuration.rb +8 -0
- data/lib/dbviewer/version.rb +1 -1
- data/lib/generators/dbviewer/templates/initializer.rb +3 -0
- metadata +6 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 54a9324f879a9f3be0d71ab93e1ead14ca185cc3513fe44c86aec45927899ff8
|
4
|
+
data.tar.gz: 139bb5d6f6bc46ef2b16e7b9c77362d3231e709cb87f79f17f2304062dd3d3a3
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
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
|
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
|
-
#
|
141
|
-
|
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
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
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"
|
data/lib/dbviewer/version.rb
CHANGED
@@ -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.
|
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-
|
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
|