quick_admin 0.1.2 → 0.1.5

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: ad5f8e560cb45b34dba0e30fa353912c5dd1db62dd8935f7823c253fd9196f3e
4
- data.tar.gz: 20086b4c332deb6cd63e630602b3e56b673bd3797f4712b3a8320b85c244392c
3
+ metadata.gz: 63bdba520fa5bce41ca4dce1e5b4a8d20c3287d06b41b9d05e013ab36e7738e5
4
+ data.tar.gz: d076140889f8f98d75bb19e5b7ba922e8c4ed07f542287982096b5b738d95fb2
5
5
  SHA512:
6
- metadata.gz: b310f44ce9cc48ff1118a5dca67cbf1d9bf7e58acd289d208862aa6d78078ba8425b2648c07873230bf11cadf46c629eb2258b10b53093879d63e850d19ee590
7
- data.tar.gz: a2dd9e3a3d9361e3db42360ef9c1b7e68121840c775a0758ac6189a9eef5f2d3d67b7c7a53a0146f49a8f7bad2762e704949f64668dea8345bdcafefdb30b49f
6
+ metadata.gz: 5c33376a983c0e6be53b301902d90237383976a29ccd209d731ad7e1598ed11ef441e0017c04d957eb0aba51c62873bba9f1c28d2564e790b85386c7f85c3041
7
+ data.tar.gz: 672e6998f1aa925e6fc49ce67c83d747458fe002e62172685f76e669dc8cc1f3b479d93ce19f8ae746c7931d84db4942849b4d537cc926c87a3c77a0ce650fba
@@ -1,152 +1,167 @@
1
1
  // QuickAdmin JavaScript Application
2
2
 
3
3
  // Simple vanilla JS approach to avoid import issues
4
- document.addEventListener('DOMContentLoaded', function() {
5
-
4
+ document.addEventListener("DOMContentLoaded", function () {
5
+ // Track if confirmation was already shown for a specific action
6
+ let confirmationShown = false;
7
+
6
8
  // Global confirmation handler for delete and dangerous actions
7
- // Works without turbo-rails by reading data-turbo-confirm or data-confirm
8
- document.addEventListener('click', function(e) {
9
- const el = e.target.closest('[data-turbo-confirm],[data-confirm]');
10
- if (!el) return;
11
- const msg = el.getAttribute('data-turbo-confirm') || el.getAttribute('data-confirm');
12
- if (msg && !window.confirm(msg)) {
13
- e.preventDefault();
14
- e.stopImmediatePropagation();
15
- }
16
- }, true);
17
-
18
- // Turbo-specific click handler for anchors (covers data-turbo-method)
19
- document.addEventListener('turbo:click', function(e) {
20
- const link = e.target && e.target.closest && e.target.closest('a[data-turbo-confirm],a[data-confirm]');
21
- if (!link) return;
22
- const msg = link.getAttribute('data-turbo-confirm') || link.getAttribute('data-confirm');
23
- if (msg && !window.confirm(msg)) {
24
- e.preventDefault();
25
- e.stopImmediatePropagation();
26
- }
27
- }, true);
28
-
29
- // Final guard: confirmation right before Turbo sends the request
30
- document.addEventListener('turbo:before-fetch-request', function(e) {
31
- const el = e.target && (e.target.closest ? e.target.closest('[data-turbo-confirm],[data-confirm]') : null);
32
- if (!el) return;
33
- const msg = el.getAttribute('data-turbo-confirm') || el.getAttribute('data-confirm');
34
- if (msg && !window.confirm(msg)) {
35
- e.preventDefault();
36
- }
37
- }, true);
38
-
39
- document.addEventListener('submit', function(e) {
40
- const form = e.target;
41
- if (!(form instanceof HTMLFormElement)) return;
42
- // Prefer the exact submitter (button) the user clicked
43
- const submitter = e.submitter || document.activeElement || form.querySelector('[type="submit"]');
44
- let msg = null;
45
- if (submitter) {
46
- msg = submitter.getAttribute('data-turbo-confirm') || submitter.getAttribute('data-confirm');
47
- }
48
- if (!msg) {
49
- msg = form.getAttribute('data-turbo-confirm') || form.getAttribute('data-confirm');
50
- }
51
- if (msg && !window.confirm(msg)) {
52
- e.preventDefault();
53
- e.stopImmediatePropagation();
54
- }
55
- }, true);
56
-
9
+ // Only handle links with data-turbo-method (delete links)
10
+ document.addEventListener(
11
+ "click",
12
+ function (e) {
13
+ const el = e.target.closest("a[data-turbo-confirm],a[data-confirm]");
14
+ if (!el) return;
15
+ // Only show confirmation for links with turbo-method (destructive actions)
16
+ if (!el.hasAttribute("data-turbo-method")) return;
17
+
18
+ const msg =
19
+ el.getAttribute("data-turbo-confirm") ||
20
+ el.getAttribute("data-confirm");
21
+ if (msg && !confirmationShown) {
22
+ confirmationShown = true;
23
+ if (!window.confirm(msg)) {
24
+ e.preventDefault();
25
+ e.stopImmediatePropagation();
26
+ }
27
+ // Reset after a short delay
28
+ setTimeout(function () {
29
+ confirmationShown = false;
30
+ }, 100);
31
+ }
32
+ },
33
+ true
34
+ );
35
+
36
+ // Form submit confirmation - only for bulk delete form
37
+ document.addEventListener(
38
+ "submit",
39
+ function (e) {
40
+ const form = e.target;
41
+ if (!(form instanceof HTMLFormElement)) return;
42
+
43
+ // Only handle bulk delete forms (forms with data-bulk-actions)
44
+ if (!form.hasAttribute("data-bulk-actions")) return;
45
+
46
+ // Check if the submit button was clicked (not auto-submit from filters)
47
+ const submitter = e.submitter;
48
+ if (!submitter || !submitter.hasAttribute("data-bulk-actions")) return;
49
+
50
+ const msg =
51
+ form.getAttribute("data-turbo-confirm") ||
52
+ form.getAttribute("data-confirm");
53
+ if (msg && !confirmationShown) {
54
+ confirmationShown = true;
55
+ if (!window.confirm(msg)) {
56
+ e.preventDefault();
57
+ e.stopImmediatePropagation();
58
+ }
59
+ setTimeout(function () {
60
+ confirmationShown = false;
61
+ }, 100);
62
+ }
63
+ },
64
+ true
65
+ );
66
+
57
67
  // Modal functionality
58
- document.addEventListener('click', function(e) {
68
+ document.addEventListener("click", function (e) {
59
69
  // Close modal when clicking overlay
60
- if (e.target.classList.contains('modal-overlay')) {
70
+ if (e.target.classList.contains("modal-overlay")) {
61
71
  closeModal();
62
72
  }
63
-
73
+
64
74
  // Close modal when clicking close button
65
- if (e.target.classList.contains('modal-close') ||
66
- e.target.hasAttribute('data-action') && e.target.getAttribute('data-action').includes('modal#close')) {
75
+ if (
76
+ e.target.classList.contains("modal-close") ||
77
+ (e.target.hasAttribute("data-action") &&
78
+ e.target.getAttribute("data-action").includes("modal#close"))
79
+ ) {
67
80
  e.preventDefault();
68
81
  closeModal();
69
82
  }
70
83
  });
71
-
84
+
72
85
  // Close modal on Escape key
73
- document.addEventListener('keydown', function(e) {
74
- if (e.key === 'Escape') {
86
+ document.addEventListener("keydown", function (e) {
87
+ if (e.key === "Escape") {
75
88
  closeModal();
76
89
  }
77
90
  });
78
-
91
+
79
92
  function closeModal() {
80
- const modal = document.getElementById('modal');
93
+ const modal = document.getElementById("modal");
81
94
  if (modal) {
82
- modal.innerHTML = '';
83
- document.body.style.overflow = '';
95
+ modal.innerHTML = "";
96
+ document.body.style.overflow = "";
84
97
  }
85
98
  }
86
-
99
+
87
100
  // Search functionality with debouncing
88
101
  function initializeSearch() {
89
102
  const searchInputs = document.querySelectorAll('[data-search="input"]');
90
- searchInputs.forEach(function(input) {
91
- if (!input.hasAttribute('data-initialized')) {
103
+ searchInputs.forEach(function (input) {
104
+ if (!input.hasAttribute("data-initialized")) {
92
105
  let timeout;
93
- input.addEventListener('input', function() {
106
+ input.addEventListener("input", function () {
94
107
  clearTimeout(timeout);
95
- timeout = setTimeout(function() {
96
- input.closest('form').submit();
108
+ timeout = setTimeout(function () {
109
+ input.closest("form").submit();
97
110
  }, 300);
98
111
  });
99
- input.setAttribute('data-initialized', 'true');
112
+ input.setAttribute("data-initialized", "true");
100
113
  }
101
114
  });
102
115
  }
103
-
116
+
104
117
  // Filter functionality
105
118
  function initializeFilters() {
106
119
  const filterSelects = document.querySelectorAll('[data-filter="select"]');
107
- filterSelects.forEach(function(select) {
108
- if (!select.hasAttribute('data-initialized')) {
109
- select.addEventListener('change', function() {
110
- select.closest('form').submit();
120
+ filterSelects.forEach(function (select) {
121
+ if (!select.hasAttribute("data-initialized")) {
122
+ select.addEventListener("change", function () {
123
+ select.closest("form").submit();
111
124
  });
112
- select.setAttribute('data-initialized', 'true');
125
+ select.setAttribute("data-initialized", "true");
113
126
  }
114
127
  });
115
128
  }
116
-
129
+
117
130
  // Initialize search and filters on page load
118
131
  initializeSearch();
119
132
  initializeFilters();
120
-
133
+
121
134
  // Bulk actions functionality
122
135
  function initializeBulkActions() {
123
136
  const bulkForms = document.querySelectorAll('[data-bulk-actions="form"]');
124
- bulkForms.forEach(function(form) {
137
+ bulkForms.forEach(function (form) {
125
138
  const toggleAll = form.querySelector('[data-bulk-actions="toggle-all"]');
126
139
  const items = form.querySelectorAll('[data-bulk-actions="item"]');
127
140
  const submitBtn = form.querySelector('[data-bulk-actions="submit"]');
128
-
141
+
129
142
  // Remove existing event listeners to prevent duplicates
130
- if (toggleAll && !toggleAll.hasAttribute('data-initialized')) {
131
- toggleAll.addEventListener('change', function() {
143
+ if (toggleAll && !toggleAll.hasAttribute("data-initialized")) {
144
+ toggleAll.addEventListener("change", function () {
132
145
  const checked = toggleAll.checked;
133
- items.forEach(function(item) {
146
+ items.forEach(function (item) {
134
147
  item.checked = checked;
135
148
  });
136
149
  updateSubmitButton();
137
150
  });
138
- toggleAll.setAttribute('data-initialized', 'true');
151
+ toggleAll.setAttribute("data-initialized", "true");
139
152
  }
140
-
141
- items.forEach(function(item) {
142
- if (!item.hasAttribute('data-initialized')) {
143
- item.addEventListener('change', updateSubmitButton);
144
- item.setAttribute('data-initialized', 'true');
153
+
154
+ items.forEach(function (item) {
155
+ if (!item.hasAttribute("data-initialized")) {
156
+ item.addEventListener("change", updateSubmitButton);
157
+ item.setAttribute("data-initialized", "true");
145
158
  }
146
159
  });
147
-
160
+
148
161
  function updateSubmitButton() {
149
- const checkedItems = form.querySelectorAll('[data-bulk-actions="item"]:checked');
162
+ const checkedItems = form.querySelectorAll(
163
+ '[data-bulk-actions="item"]:checked'
164
+ );
150
165
  if (submitBtn) {
151
166
  submitBtn.disabled = checkedItems.length === 0;
152
167
  if (checkedItems.length > 0) {
@@ -158,38 +173,43 @@ document.addEventListener('DOMContentLoaded', function() {
158
173
  }
159
174
  });
160
175
  }
161
-
176
+
162
177
  // Initialize bulk actions on page load
163
178
  initializeBulkActions();
164
-
179
+
165
180
  // Re-initialize all components after turbo updates
166
- document.addEventListener('turbo:frame-load', function(event) {
167
- console.log('Turbo frame loaded:', event.target.id);
181
+ document.addEventListener("turbo:frame-load", function (event) {
182
+ console.log("Turbo frame loaded:", event.target.id);
168
183
  initializeSearch();
169
184
  initializeFilters();
170
185
  initializeBulkActions();
171
186
  initializeAlerts();
172
187
  });
173
-
188
+
174
189
  // Also reinitialize after turbo stream actions
175
- document.addEventListener('turbo:before-stream-render', function(event) {
176
- console.log('Turbo stream before render:', event.detail.newStream);
190
+ document.addEventListener("turbo:before-stream-render", function (event) {
191
+ console.log("Turbo stream before render:", event.detail.newStream);
177
192
  // Clear existing initializations so they can be re-added
178
- document.querySelectorAll('[data-initialized]').forEach(function(element) {
179
- element.removeAttribute('data-initialized');
193
+ document.querySelectorAll("[data-initialized]").forEach(function (element) {
194
+ element.removeAttribute("data-initialized");
180
195
  });
181
196
  });
182
-
197
+
183
198
  // Additional event listeners for debugging and ensuring functionality
184
- document.addEventListener('turbo:submit-start', function(event) {
185
- console.log('Form submission started:', event.target);
199
+ document.addEventListener("turbo:submit-start", function (event) {
200
+ console.log("Form submission started:", event.target);
186
201
  });
187
-
188
- document.addEventListener('turbo:submit-end', function(event) {
189
- console.log('Form submission ended:', event.target, 'Success:', event.detail.success);
202
+
203
+ document.addEventListener("turbo:submit-end", function (event) {
204
+ console.log(
205
+ "Form submission ended:",
206
+ event.target,
207
+ "Success:",
208
+ event.detail.success
209
+ );
190
210
  if (event.detail.success) {
191
211
  // Reinitialize everything after successful form submission
192
- setTimeout(function() {
212
+ setTimeout(function () {
193
213
  initializeSearch();
194
214
  initializeFilters();
195
215
  initializeBulkActions();
@@ -197,25 +217,27 @@ document.addEventListener('DOMContentLoaded', function() {
197
217
  }, 100);
198
218
  }
199
219
  });
200
-
220
+
201
221
  // Handle turbo stream rendering completion
202
- document.addEventListener('turbo:before-stream-render', function() {
222
+ document.addEventListener("turbo:before-stream-render", function () {
203
223
  // Clear all flash messages before new ones are added
204
- document.querySelectorAll('.flash-message').forEach(function(msg) {
224
+ document.querySelectorAll(".flash-message").forEach(function (msg) {
205
225
  msg.remove();
206
226
  });
207
227
  });
208
-
228
+
209
229
  // Auto-dismiss alerts function
210
230
  function initializeAlerts() {
211
- const alerts = document.querySelectorAll('[data-auto-dismiss="true"]:not([data-alert-initialized])');
212
- alerts.forEach(function(alert) {
213
- alert.setAttribute('data-alert-initialized', 'true');
214
- setTimeout(function() {
231
+ const alerts = document.querySelectorAll(
232
+ '[data-auto-dismiss="true"]:not([data-alert-initialized])'
233
+ );
234
+ alerts.forEach(function (alert) {
235
+ alert.setAttribute("data-alert-initialized", "true");
236
+ setTimeout(function () {
215
237
  if (alert.parentNode) {
216
- alert.style.opacity = '0';
217
- alert.style.transform = 'translateY(-10px)';
218
- setTimeout(function() {
238
+ alert.style.opacity = "0";
239
+ alert.style.transform = "translateY(-10px)";
240
+ setTimeout(function () {
219
241
  if (alert.parentNode) {
220
242
  alert.remove();
221
243
  }
@@ -224,22 +246,21 @@ document.addEventListener('DOMContentLoaded', function() {
224
246
  }, 3000);
225
247
  });
226
248
  }
227
-
249
+
228
250
  // Initialize alerts on page load
229
251
  initializeAlerts();
230
-
252
+
231
253
  // Alert close buttons
232
- document.addEventListener('click', function(e) {
233
- if (e.target.classList.contains('alert-close')) {
234
- const alert = e.target.closest('.alert');
254
+ document.addEventListener("click", function (e) {
255
+ if (e.target.classList.contains("alert-close")) {
256
+ const alert = e.target.closest(".alert");
235
257
  if (alert) {
236
- alert.style.opacity = '0';
237
- alert.style.transform = 'translateY(-10px)';
238
- setTimeout(function() {
258
+ alert.style.opacity = "0";
259
+ alert.style.transform = "translateY(-10px)";
260
+ setTimeout(function () {
239
261
  alert.remove();
240
262
  }, 300);
241
263
  }
242
264
  }
243
265
  });
244
-
245
266
  });
@@ -86,6 +86,42 @@ body {
86
86
  padding-left: 0.5rem;
87
87
  }
88
88
 
89
+ /* Navigation Footer */
90
+ .nav-footer {
91
+ position: absolute;
92
+ bottom: 0;
93
+ left: 0;
94
+ right: 0;
95
+ padding: 1rem;
96
+ border-top: 1px solid rgba(255,255,255,0.1);
97
+ background-color: var(--dark-color);
98
+ }
99
+
100
+ .user-info {
101
+ margin-bottom: 0.5rem;
102
+ }
103
+
104
+ .user-email {
105
+ color: rgba(255,255,255,0.7);
106
+ font-size: 0.85rem;
107
+ display: block;
108
+ overflow: hidden;
109
+ text-overflow: ellipsis;
110
+ white-space: nowrap;
111
+ }
112
+
113
+ .sign-out-link {
114
+ color: rgba(255,255,255,0.8);
115
+ text-decoration: none;
116
+ padding: 0.5rem 0;
117
+ display: block;
118
+ font-size: 0.9rem;
119
+ }
120
+
121
+ .sign-out-link:hover {
122
+ color: var(--danger-color);
123
+ }
124
+
89
125
  /* Buttons */
90
126
  .btn {
91
127
  display: inline-block;
@@ -10,16 +10,28 @@ module QuickAdmin
10
10
  private
11
11
 
12
12
  def authenticate_admin_user!
13
- if QuickAdmin.config.devise_enabled?
14
- authenticate_user!
13
+ auth_method = QuickAdmin.config.authenticate_method
14
+
15
+ if auth_method.present?
16
+ # Use explicitly configured method
17
+ send(auth_method)
18
+ elsif defined?(Devise)
19
+ # Auto-detect Devise authentication method
20
+ if respond_to?(:authenticate_admin!, true)
21
+ authenticate_admin!
22
+ elsif respond_to?(:authenticate_user!, true)
23
+ authenticate_user!
24
+ end
15
25
  elsif QuickAdmin.config.authentication == :custom
16
- # Override this method in your application
26
+ # Custom authentication - override admin_authenticated? in your app
17
27
  head :unauthorized unless admin_authenticated?
18
28
  end
19
29
  end
20
30
 
21
31
  def authentication_required?
22
- QuickAdmin.config.authentication.present?
32
+ QuickAdmin.config.authentication.present? ||
33
+ QuickAdmin.config.authenticate_method.present? ||
34
+ defined?(Devise)
23
35
  end
24
36
 
25
37
  def admin_authenticated?
@@ -113,7 +113,13 @@ module QuickAdmin
113
113
  end
114
114
 
115
115
  def set_resource
116
- @resource = resource_class.find(params[:id])
116
+ find_field = @resource_config.find_by_field
117
+
118
+ if find_field == 'id'
119
+ @resource = resource_class.find(params[:id])
120
+ else
121
+ @resource = resource_class.find_by!(find_field => params[:id])
122
+ end
117
123
  end
118
124
 
119
125
  def resource_name
@@ -1,6 +1,20 @@
1
1
  module QuickAdmin
2
2
  module ApplicationHelper
3
3
  def display_field_value(resource, field)
4
+ # Check if this is a belongs_to association (field ends with _id)
5
+ if field.to_s.end_with?('_id')
6
+ association_name = field.to_s.sub(/_id$/, '')
7
+ if resource.class.reflect_on_association(association_name.to_sym)
8
+ associated = resource.send(association_name)
9
+ if associated
10
+ # Try common display methods
11
+ return associated.try(:name) || associated.try(:title) || associated.try(:to_s) || associated.id
12
+ else
13
+ return content_tag(:span, "—", class: "null-value")
14
+ end
15
+ end
16
+ end
17
+
4
18
  value = resource.send(field)
5
19
 
6
20
  case value
@@ -57,6 +71,26 @@ module QuickAdmin
57
71
  end
58
72
 
59
73
  def render_form_field(form, resource, field)
74
+ # Check if this is a belongs_to association (field ends with _id)
75
+ if field.to_s.end_with?('_id')
76
+ association_name = field.to_s.sub(/_id$/, '')
77
+ association = resource.class.reflect_on_association(association_name.to_sym)
78
+ if association
79
+ associated_class = association.klass
80
+ options = associated_class.all.map do |record|
81
+ display = record.try(:name) || record.try(:title) || record.to_s
82
+ [display, record.id]
83
+ end
84
+ return form.select(field, options, { include_blank: "Select #{association_name.humanize}" }, class: "form-control")
85
+ end
86
+ end
87
+
88
+ # Check if this is an enum field
89
+ if resource.class.respond_to?(:defined_enums) && resource.class.defined_enums.key?(field.to_s)
90
+ enum_options = resource.class.send(field.to_s.pluralize).keys.map { |k| [k.humanize, k] }
91
+ return form.select(field, enum_options, { include_blank: "Select #{field.humanize}" }, class: "form-control")
92
+ end
93
+
60
94
  column = resource.class.columns_hash[field]
61
95
 
62
96
  case column&.type
@@ -96,6 +130,20 @@ module QuickAdmin
96
130
  def filter_options_for_field(model_class, field)
97
131
  return [] unless model_class.respond_to?(:column_names)
98
132
 
133
+ # Check if this is a belongs_to association (field ends with _id)
134
+ if field.to_s.end_with?('_id')
135
+ association_name = field.to_s.sub(/_id$/, '')
136
+ association = model_class.reflect_on_association(association_name.to_sym)
137
+ if association
138
+ associated_class = association.klass
139
+ # Get all records from the associated model and display their name/title
140
+ return associated_class.all.map do |record|
141
+ display = record.try(:name) || record.try(:title) || record.to_s
142
+ [display, record.id.to_s]
143
+ end
144
+ end
145
+ end
146
+
99
147
  column = model_class.columns_hash[field]
100
148
  case column&.type
101
149
  when :boolean
@@ -106,6 +154,14 @@ module QuickAdmin
106
154
  dates = model_class.where.not(field => nil).order(field => :desc).limit(200).pluck(field)
107
155
  days = dates.map { |d| d.to_date }.uniq.first(10)
108
156
  days.map { |day| [day.strftime('%Y-%m-%d'), day.strftime('%Y-%m-%d')] }
157
+ when :integer
158
+ # Check if this is an enum field
159
+ enum_name = field.to_s.pluralize
160
+ if model_class.respond_to?(enum_name)
161
+ return model_class.send(enum_name).keys.map { |k| [k.humanize, k] }
162
+ end
163
+ values = model_class.distinct.pluck(field).compact.uniq
164
+ values.map { |v| [v.to_s, v.to_s] }
109
165
  else
110
166
  values = model_class.distinct.pluck(field).compact.uniq
111
167
  values.map { |v| [v.to_s, v.to_s] }
@@ -137,5 +193,45 @@ module QuickAdmin
137
193
  def current_resource_config
138
194
  @resource_config
139
195
  end
196
+
197
+ # Returns the param value to use in URLs for a resource.
198
+ # Uses the configured find_by_field to get the correct value.
199
+ #
200
+ # @param resource [ActiveRecord::Base] the resource record
201
+ # @return [String, Integer] the param value for URLs
202
+ def resource_param_value(resource)
203
+ return resource.id unless @resource_config
204
+
205
+ find_field = @resource_config.find_by_field
206
+ find_field == 'id' ? resource.id : resource.send(find_field)
207
+ end
208
+
209
+ # Returns the current user using the configured method.
210
+ # @return [Object, nil] the current user or nil
211
+ def quick_admin_current_user
212
+ method_name = QuickAdmin.config.resolved_current_user_method
213
+ return nil unless respond_to?(method_name, true)
214
+ send(method_name)
215
+ end
216
+
217
+ # Returns a display string for the current user.
218
+ # Tries email, name, username, or falls back to "Admin".
219
+ # @return [String] display name for the user
220
+ def quick_admin_user_display
221
+ user = quick_admin_current_user
222
+ return "Admin" unless user
223
+
224
+ user.try(:email) || user.try(:name) || user.try(:username) || "Admin"
225
+ end
226
+
227
+ # Returns the sign out path if available.
228
+ # @return [String, nil] the sign out path or nil
229
+ def quick_admin_sign_out_path
230
+ path = QuickAdmin.config.resolved_sign_out_path
231
+ return path if path.is_a?(String)
232
+
233
+ # If it's a proc, call it
234
+ path.respond_to?(:call) ? path.call : nil
235
+ end
140
236
  end
141
237
  end
@@ -24,6 +24,20 @@
24
24
  <li><%= link_to resource.display_name, quick_admin.resources_path(name), class: "nav-link" %></li>
25
25
  <% end %>
26
26
  </ul>
27
+
28
+ <div class="nav-footer">
29
+ <% if quick_admin_current_user %>
30
+ <div class="user-info">
31
+ <span class="user-email"><%= quick_admin_user_display %></span>
32
+ </div>
33
+ <% end %>
34
+ <% if quick_admin_sign_out_path %>
35
+ <%= link_to "Sign Out", quick_admin_sign_out_path,
36
+ method: QuickAdmin.config.sign_out_method,
37
+ data: { turbo_method: QuickAdmin.config.sign_out_method },
38
+ class: "nav-link sign-out-link" %>
39
+ <% end %>
40
+ </div>
27
41
  </nav>
28
42
 
29
43
  <main class="quick-admin-main">
@@ -1,10 +1,12 @@
1
1
  <%
2
2
  # Handle resource_name parameter
3
3
  resource_name_param = defined?(resource_name) ? resource_name : @resource_config.model_name
4
+ # Get the correct param value for URLs (uses configured find_by_field)
5
+ param_value = resource_param_value(resource)
4
6
  %>
5
7
 
6
8
  <%= form_with model: [QuickAdmin::Engine, resource_name_param.singularize.to_sym, resource],
7
- url: resource.persisted? ? quick_admin.resource_path(resource_name_param, resource) : quick_admin.resources_path(resource_name_param),
9
+ url: resource.persisted? ? quick_admin.resource_path(resource_name_param, param_value) : quick_admin.resources_path(resource_name_param),
8
10
  method: resource.persisted? ? :patch : :post,
9
11
  local: false,
10
12
  class: "resource-form" do |form| %>
@@ -1,6 +1,8 @@
1
1
  <%
2
2
  # Handle resource_name parameter
3
3
  resource_name_param = defined?(resource_name) ? resource_name : params[:resource_name]
4
+ # Get the correct param value for URLs (uses configured find_by_field)
5
+ param_value = resource_param_value(resource)
4
6
  %>
5
7
 
6
8
  <tr class="resource-row" id="resource_<%= resource.id %>">
@@ -14,13 +16,13 @@
14
16
  </td>
15
17
  <% end %>
16
18
  <td class="actions">
17
- <%= link_to "View", quick_admin.resource_path(resource_name_param, resource),
19
+ <%= link_to "View", quick_admin.resource_path(resource_name_param, param_value),
18
20
  class: "btn btn-sm btn-info",
19
21
  data: { turbo_frame: "modal" } %>
20
- <%= link_to "Edit", quick_admin.edit_resource_path(resource_name_param, resource),
22
+ <%= link_to "Edit", quick_admin.edit_resource_path(resource_name_param, param_value),
21
23
  class: "btn btn-sm btn-warning",
22
24
  data: { turbo_frame: "modal" } %>
23
- <%= link_to "Delete", quick_admin.resource_path(resource_name_param, resource),
25
+ <%= link_to "Delete", quick_admin.resource_path(resource_name_param, param_value),
24
26
  class: "btn btn-sm btn-danger",
25
27
  data: {
26
28
  turbo_method: :delete,
@@ -19,10 +19,11 @@
19
19
  </div>
20
20
 
21
21
  <div class="detail-actions">
22
- <%= link_to "Edit", quick_admin.edit_resource_path(resource_name, @resource),
22
+ <% param_value = resource_param_value(@resource) %>
23
+ <%= link_to "Edit", quick_admin.edit_resource_path(resource_name, param_value),
23
24
  class: "btn btn-warning",
24
25
  data: { turbo_frame: "modal" } %>
25
- <%= button_to "Delete", quick_admin.resource_path(resource_name, @resource),
26
+ <%= button_to "Delete", quick_admin.resource_path(resource_name, param_value),
26
27
  method: :delete,
27
28
  class: "btn btn-danger",
28
29
  form_class: "inline-form",
@@ -10,9 +10,14 @@ module QuickAdmin
10
10
  # @attr per_page [Integer] number of records per page (default: 25)
11
11
  # @attr mount_path [String] URL path where admin is mounted (default: '/admin')
12
12
  # @attr app_name [String] application name shown in UI (default: 'QuickAdmin')
13
+ # @attr sign_out_path [String, Proc] path for sign out link (default: auto-detect)
14
+ # @attr sign_out_method [Symbol] HTTP method for sign out (default: :delete)
15
+ # @attr current_user_method [Symbol] method to get current user (default: :current_user)
13
16
  class Configuration
14
17
  attr_accessor :authentication, :css_framework, :text_editor, :turbo_enabled,
15
- :stimulus_enabled, :per_page, :mount_path, :app_name
18
+ :stimulus_enabled, :per_page, :mount_path, :app_name,
19
+ :sign_out_path, :sign_out_method, :current_user_method,
20
+ :authenticate_method
16
21
 
17
22
  # Initializes a new Configuration with default values.
18
23
  def initialize
@@ -24,6 +29,10 @@ module QuickAdmin
24
29
  @per_page = 25 # Reasonable default pagination
25
30
  @mount_path = '/admin' # Standard admin path
26
31
  @app_name = 'QuickAdmin' # Default app name
32
+ @sign_out_path = nil # Auto-detect from Devise or set manually
33
+ @sign_out_method = :delete # Default HTTP method for sign out
34
+ @current_user_method = :current_user # Method to get current user
35
+ @authenticate_method = nil # Auto-detect or set manually (e.g., :authenticate_admin!)
27
36
  end
28
37
 
29
38
  # Checks if Devise authentication is enabled and available.
@@ -55,5 +64,42 @@ module QuickAdmin
55
64
  def lexical_enabled?
56
65
  text_editor == :lexical
57
66
  end
67
+
68
+ # Returns the resolved sign out path.
69
+ # Auto-detects Devise path if not explicitly set.
70
+ # @return [String, nil] the sign out path or nil if not available
71
+ def resolved_sign_out_path
72
+ return sign_out_path if sign_out_path.present?
73
+
74
+ # Auto-detect Devise sign out path (even if not explicitly configured)
75
+ if defined?(Devise)
76
+ # Check for Admin model first, then User
77
+ if defined?(Admin)
78
+ '/admins/sign_out'
79
+ else
80
+ '/users/sign_out'
81
+ end
82
+ else
83
+ nil
84
+ end
85
+ end
86
+
87
+ # Returns the resolved current user method.
88
+ # Auto-detects based on available Devise models.
89
+ # @return [Symbol] the method name to get current user
90
+ def resolved_current_user_method
91
+ return current_user_method if current_user_method != :current_user
92
+
93
+ # Auto-detect based on Devise models
94
+ if defined?(Devise)
95
+ if defined?(Admin)
96
+ :current_admin
97
+ else
98
+ :current_user
99
+ end
100
+ else
101
+ current_user_method
102
+ end
103
+ end
58
104
  end
59
105
  end
@@ -7,9 +7,10 @@ module QuickAdmin
7
7
  # @attr filterable_fields [Array<String>] fields that can be filtered
8
8
  # @attr editable_fields [Array<String>] fields that can be edited
9
9
  # @attr display_name [String] human-friendly name for the resource
10
+ # @attr param_key [String] the field to use for finding records (default: id)
10
11
  class Resource
11
12
  attr_accessor :model_name, :searchable_fields,
12
- :filterable_fields, :editable_fields, :display_name
13
+ :filterable_fields, :editable_fields, :display_name, :param_key
13
14
 
14
15
  # Initializes a new Resource.
15
16
  #
@@ -21,6 +22,7 @@ module QuickAdmin
21
22
  @filterable_fields = []
22
23
  @editable_fields = []
23
24
  @display_name = model_name.to_s.humanize.pluralize
25
+ @param_key = nil # Will auto-detect or default to :id
24
26
  end
25
27
 
26
28
  # Returns the actual model class for this resource.
@@ -86,6 +88,38 @@ module QuickAdmin
86
88
  @display_name
87
89
  end
88
90
 
91
+ # Gets or sets the param key used for finding records.
92
+ # Use this when your model overrides to_param (e.g., uses slug instead of id).
93
+ #
94
+ # @param field [Symbol, String, nil] optional field name to set
95
+ # @return [String] the param key field name
96
+ #
97
+ # @example
98
+ # r.param :slug # Use slug field for finding records
99
+ def param(field = nil)
100
+ @param_key = field.to_s if field
101
+ @param_key
102
+ end
103
+
104
+ # Returns the field to use for finding records.
105
+ # If param_key is set, uses that. Otherwise, auto-detects if model
106
+ # overrides to_param and tries to find the matching field.
107
+ #
108
+ # @return [String] the field name to use for finding records
109
+ def find_by_field
110
+ return @param_key if @param_key.present?
111
+
112
+ # Auto-detect: check if model overrides to_param
113
+ if model_class && model_class.instance_methods(false).include?(:to_param)
114
+ # Common patterns: slug, uuid, token, etc.
115
+ %w[slug uuid token permalink].each do |field|
116
+ return field if model_class.column_names.include?(field)
117
+ end
118
+ end
119
+
120
+ 'id'
121
+ end
122
+
89
123
  private
90
124
 
91
125
  # Returns default fields from the model if no fields are explicitly set.
@@ -93,7 +127,7 @@ module QuickAdmin
93
127
  #
94
128
  # @return [Array<String>] list of column names
95
129
  def default_fields
96
- return [] unless model_class&.respond_to?(:column_names)
130
+ return [] unless model_class && model_class.respond_to?(:column_names)
97
131
 
98
132
  excluded_fields = %w[id created_at updated_at]
99
133
  model_class.column_names.reject { |field| excluded_fields.include?(field) }
@@ -1,3 +1,3 @@
1
1
  module QuickAdmin
2
- VERSION = "0.1.2"
2
+ VERSION = "0.1.5"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: quick_admin
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.2
4
+ version: 0.1.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - Nezir Zahirovic
@@ -142,7 +142,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
142
142
  - !ruby/object:Gem::Version
143
143
  version: 2.0.0
144
144
  requirements: []
145
- rubygems_version: 3.7.2
145
+ rubygems_version: 3.6.9
146
146
  specification_version: 4
147
147
  summary: Simple, minimal Rails admin interface
148
148
  test_files: []