admin_suite 0.2.2 → 0.2.4

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: 22d3fb1dd5649136ae88ea54aa4b948790c1e1fd7a81681ffd07707f82d6494a
4
- data.tar.gz: 00c3ba3433eb3d9d59289c70d75fe9b0a3f613a043b6dc1d109b7af4a375272e
3
+ metadata.gz: ac7ab643c69ac46cf31a6b48734cd58cae60233e8284a738147b419e4982fde3
4
+ data.tar.gz: b37617cd8e1f00fef844f95ddb81800f12990d48940c1a7d14cf47de1fd7e64e
5
5
  SHA512:
6
- metadata.gz: b104f41152647c491a4f318beb6d1b2ea7f69619af6c7cb9ee05fef3a32b719aef73f48200e6b0bd5efc4c2ad7fe85637db40dc3023d63a3281cd39882539b7f
7
- data.tar.gz: 8d27c97e8790b50d59f769abacdc1a19158158fcb284d31b6e3785fcb8064e3020ef8577e3b1960681038314ea865e1502ba408e7db8359924ea5accc39bb99e
6
+ metadata.gz: d845685a1eabba91a371b975f3599bdf282a3940af720a9feb545751e044544a0aa8818611e742ebb37728fe86c5af41bb511462a211e9fcdc8e286649a67a05
7
+ data.tar.gz: dcc33dd1079fe5da4357445b939742d7cada340f858deabc45bdc3531dad59fe46a7c77c9ba33d9df5b0bc3ffe9f9c7b163a46b4484c3c99c5825e38f947acf9
@@ -508,6 +508,7 @@ button[type="submit"]:hover,
508
508
  transition: background-color 0.2s ease-in-out;
509
509
  background: #e2e8f0; /* slate-200 (off) */
510
510
  vertical-align: middle;
511
+ overflow: hidden;
511
512
  }
512
513
 
513
514
  .admin-suite-toggle-wrap {
@@ -96,6 +96,7 @@ module AdminSuite
96
96
  # POST /:portal/:resource_name/:id/toggle
97
97
  def toggle
98
98
  field = params[:field].presence&.to_sym
99
+ field ||= toggleable_fields.first if toggleable_fields.one?
99
100
  unless field
100
101
  head :unprocessable_entity
101
102
  return
@@ -112,7 +113,7 @@ module AdminSuite
112
113
  respond_to do |format|
113
114
  format.turbo_stream do
114
115
  render turbo_stream: turbo_stream.replace(
115
- dom_id(@resource, :toggle),
116
+ dom_id(@resource, "#{field}_toggle"),
116
117
  partial: "admin_suite/shared/toggle_cell",
117
118
  locals: { record: @resource, field: field, toggle_url: resource_toggle_path(portal: current_portal, resource_name: resource_name, id: @resource.to_param, field: field) }
118
119
  )
@@ -144,23 +145,20 @@ module AdminSuite
144
145
  end
145
146
 
146
147
  def set_resource
147
- @resource = resource_class.find(params[:id])
148
- rescue ActiveRecord::RecordNotFound
149
- # Support "friendly" params (e.g. slugged records) without requiring host apps
150
- # to change their model primary keys.
148
+ klass = resource_class
151
149
  id = params[:id].to_s
152
- columns = resource_class.column_names
153
-
154
- @resource =
155
- if columns.include?("slug")
156
- resource_class.find_by!(slug: id)
157
- elsif columns.include?("uuid")
158
- resource_class.find_by!(uuid: id)
159
- elsif columns.include?("token")
160
- resource_class.find_by!(token: id)
161
- else
162
- raise
163
- end
150
+ columns = klass.column_names
151
+
152
+ # Prevent ActiveRecord from coercing UUID-ish params like "2ce3-..."
153
+ # into integer ids (e.g., 2) for integer primary keys.
154
+ if non_numeric_id_for_numeric_primary_key?(klass, id)
155
+ @resource = find_friendly_resource!(klass, id, columns)
156
+ return
157
+ end
158
+
159
+ @resource = klass.find(id)
160
+ rescue ActiveRecord::RecordNotFound
161
+ @resource = find_friendly_resource!(klass, id, columns)
164
162
  end
165
163
 
166
164
  def resource
@@ -218,13 +216,22 @@ module AdminSuite
218
216
  end
219
217
 
220
218
  def toggleable_fields
221
- return [] unless resource_config&.index_config&.columns_list
219
+ return [] unless resource_config
222
220
 
223
- resource_config.index_config.columns_list.filter_map do |col|
221
+ index_fields = resource_config.index_config&.columns_list&.filter_map do |col|
224
222
  next unless col.type == :toggle
225
223
 
226
224
  (col.toggle_field || col.name).to_sym
227
- end
225
+ end || []
226
+
227
+ form_fields = resource_config.form_config&.fields_list&.filter_map do |field|
228
+ next unless field.is_a?(Admin::Base::Resource::FieldDefinition)
229
+ next unless field.type == :toggle
230
+
231
+ field.name.to_sym
232
+ end || []
233
+
234
+ (index_fields + form_fields).uniq
228
235
  end
229
236
 
230
237
  def resource_url(record)
@@ -234,5 +241,33 @@ module AdminSuite
234
241
  def collection_url
235
242
  resources_path(portal: current_portal, resource_name: resource_name)
236
243
  end
244
+
245
+ def find_friendly_resource!(klass, id, columns = klass.column_names)
246
+ if columns.include?("slug")
247
+ record = klass.find_by(slug: id)
248
+ return record if record
249
+ end
250
+
251
+ if columns.include?("uuid")
252
+ record = klass.find_by(uuid: id)
253
+ return record if record
254
+ end
255
+
256
+ if columns.include?("token")
257
+ record = klass.find_by(token: id)
258
+ return record if record
259
+ end
260
+
261
+ raise ActiveRecord::RecordNotFound
262
+ end
263
+
264
+ def non_numeric_id_for_numeric_primary_key?(klass, id)
265
+ primary_key = klass.primary_key.to_s
266
+ return false if primary_key.blank?
267
+
268
+ pk_type = klass.columns_hash[primary_key]&.type
269
+ numeric_pk = %i[integer bigint].include?(pk_type)
270
+ numeric_pk && id !~ /\A\d+\z/
271
+ end
237
272
  end
238
273
  end
@@ -28,6 +28,45 @@ module AdminSuite
28
28
  end
29
29
  end
30
30
 
31
+ # Logout path/method/label in the topbar are host-configurable.
32
+ def admin_suite_logout_path
33
+ value = AdminSuite.config.respond_to?(:logout_path) ? AdminSuite.config.logout_path : nil
34
+ resolve_admin_suite_view_config(value).presence
35
+ end
36
+
37
+ def admin_suite_logout_method
38
+ value = AdminSuite.config.respond_to?(:logout_method) ? AdminSuite.config.logout_method : :delete
39
+ resolved = resolve_admin_suite_view_config(value)
40
+ resolved = resolved.to_sym if resolved.respond_to?(:to_sym)
41
+ resolved.presence || :delete
42
+ rescue StandardError
43
+ :delete
44
+ end
45
+
46
+ def admin_suite_logout_label
47
+ value = AdminSuite.config.respond_to?(:logout_label) ? AdminSuite.config.logout_label : nil
48
+ resolved = resolve_admin_suite_view_config(value)
49
+ resolved.to_s.presence || "Log out"
50
+ end
51
+
52
+ def resolve_admin_suite_view_config(value)
53
+ return nil if value.nil?
54
+
55
+ if value.respond_to?(:call)
56
+ return value.call if value.arity.zero?
57
+ return value.call(self)
58
+ end
59
+
60
+ if value.is_a?(Symbol)
61
+ return nil unless respond_to?(value, true)
62
+ return public_send(value)
63
+ end
64
+
65
+ value
66
+ rescue StandardError
67
+ nil
68
+ end
69
+
31
70
  # Lookup the DSL field definition for a given attribute (if present).
32
71
  #
33
72
  # Used to render show values with type awareness (e.g. markdown/json/label).
@@ -1040,6 +1079,16 @@ module AdminSuite
1040
1079
  current_label = if current_value.present? && collection.is_a?(Array)
1041
1080
  match = collection.find { |opt| opt.is_a?(Array) ? opt[1].to_s == current_value.to_s : opt.to_s == current_value.to_s }
1042
1081
  match.is_a?(Array) ? match[0] : match.to_s
1082
+ elsif current_value.present? && collection.is_a?(String)
1083
+ association_name = field.name.to_s.sub(/_id\z/, "")
1084
+ assoc = resource.public_send(association_name) if resource.respond_to?(association_name)
1085
+ if assoc.respond_to?(:name) && assoc.name.present?
1086
+ assoc.name
1087
+ elsif assoc.respond_to?(:title) && assoc.title.present?
1088
+ assoc.title
1089
+ else
1090
+ current_value
1091
+ end
1043
1092
  else
1044
1093
  current_value
1045
1094
  end
@@ -1049,7 +1098,8 @@ module AdminSuite
1049
1098
  controller: "admin-suite--searchable-select",
1050
1099
  "admin-suite--searchable-select-options-value": options_json,
1051
1100
  "admin-suite--searchable-select-creatable-value": field.create_url.present?,
1052
- "admin-suite--searchable-select-search-url-value": collection.is_a?(String) ? collection : ""
1101
+ "admin-suite--searchable-select-search-url-value": collection.is_a?(String) ? collection : "",
1102
+ "admin-suite--searchable-select-create-url-value": field.create_url.to_s
1053
1103
  },
1054
1104
  class: "relative") do
1055
1105
  concat(hidden_field_tag("#{param_key}[#{field.name}]", current_value, data: { "admin-suite--searchable-select-target": "input" }))
@@ -1062,7 +1112,7 @@ module AdminSuite
1062
1112
  action: "input->admin-suite--searchable-select#search focus->admin-suite--searchable-select#open keydown->admin-suite--searchable-select#keydown"
1063
1113
  }))
1064
1114
  concat(content_tag(:div, "",
1065
- class: "absolute z-10 w-full mt-1 bg-white border border-slate-200 rounded-lg shadow-lg hidden max-h-60 overflow-y-auto",
1115
+ class: "absolute z-40 w-full mt-1 bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-lg shadow-lg hidden max-h-60 overflow-y-auto",
1066
1116
  data: { "admin-suite--searchable-select-target": "dropdown" }))
1067
1117
  end
1068
1118
  end
@@ -0,0 +1,45 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // Connects to data-controller="admin-suite--flash"
4
+ export default class extends Controller {
5
+ static targets = ["notification"]
6
+
7
+ connect() {
8
+ this.timeout = setTimeout(() => this.dismissAll(), 5000)
9
+ }
10
+
11
+ dismiss(event) {
12
+ if (event) {
13
+ event.preventDefault()
14
+ }
15
+
16
+ const notification = event?.currentTarget?.closest('[data-admin-suite--flash-target="notification"]')
17
+ if (notification) {
18
+ this.fadeOut(notification)
19
+ }
20
+ }
21
+
22
+ dismissAll() {
23
+ this.notificationTargets.forEach((notification) => this.fadeOut(notification))
24
+ }
25
+
26
+ fadeOut(element) {
27
+ if (!element || !element.isConnected) return
28
+
29
+ element.style.transition = "opacity 0.25s ease-out, transform 0.25s ease-out"
30
+ element.style.opacity = "0"
31
+ element.style.transform = "translateX(8px)"
32
+
33
+ setTimeout(() => {
34
+ if (element.isConnected) {
35
+ element.remove()
36
+ }
37
+ }, 250)
38
+ }
39
+
40
+ disconnect() {
41
+ if (this.timeout) {
42
+ clearTimeout(this.timeout)
43
+ }
44
+ }
45
+ }
@@ -11,6 +11,7 @@ export default class extends Controller {
11
11
  options: { type: Array, default: [] },
12
12
  creatable: { type: Boolean, default: false },
13
13
  searchUrl: { type: String, default: "" },
14
+ createUrl: { type: String, default: "" },
14
15
  }
15
16
 
16
17
  connect() {
@@ -55,16 +56,8 @@ export default class extends Controller {
55
56
  opt.label.toLowerCase().includes(query),
56
57
  )
57
58
 
58
- if (
59
- this.creatableValue &&
60
- query &&
61
- !this.filteredOptions.some((o) => o.value === query)
62
- ) {
63
- this.filteredOptions.push({
64
- value: query,
65
- label: `Create "${query}"`,
66
- isNew: true,
67
- })
59
+ if (this.creatableValue && query && !this.hasExactOptionMatch(query)) {
60
+ this.filteredOptions.push(this.buildCreateOption(query))
68
61
  }
69
62
 
70
63
  this.renderDropdown()
@@ -81,19 +74,11 @@ export default class extends Controller {
81
74
  const data = await response.json()
82
75
  this.filteredOptions = data.map((item) => ({
83
76
  value: item.id || item.value,
84
- label: item.name || item.label,
77
+ label: item.name || item.title || item.label,
85
78
  }))
86
79
 
87
- if (
88
- this.creatableValue &&
89
- query &&
90
- !this.filteredOptions.some((o) => o.value === query)
91
- ) {
92
- this.filteredOptions.push({
93
- value: query,
94
- label: `Create "${query}"`,
95
- isNew: true,
96
- })
80
+ if (this.creatableValue && query && !this.hasExactOptionMatch(query)) {
81
+ this.filteredOptions.push(this.buildCreateOption(query))
97
82
  }
98
83
 
99
84
  this.renderDropdown()
@@ -117,7 +102,9 @@ export default class extends Controller {
117
102
  class="block w-full text-left px-3 py-2 text-sm hover:bg-slate-100 ${index === this.selectedIndex ? "bg-slate-100" : ""} ${opt.isNew ? "text-indigo-600 font-medium" : "text-slate-700"}"
118
103
  data-action="click->admin-suite--searchable-select#select"
119
104
  data-value="${opt.value}"
120
- data-label="${opt.label}">
105
+ data-label="${opt.label}"
106
+ data-create-label="${opt.createLabel || ""}"
107
+ data-is-new="${opt.isNew ? "true" : "false"}">
121
108
  ${opt.label}
122
109
  </button>
123
110
  `,
@@ -125,18 +112,12 @@ export default class extends Controller {
125
112
  .join("")
126
113
  }
127
114
 
128
- select(event) {
129
- const value = event.currentTarget.dataset.value
130
- const label = event.currentTarget.dataset.label
131
- .replace(/^Create "/, "")
132
- .replace(/"$/, "")
133
-
134
- this.inputTarget.value = value
135
- this.searchTarget.value = label
136
- this.close()
115
+ async select(event) {
116
+ const option = this.optionFromElement(event.currentTarget)
117
+ await this.applyOption(option)
137
118
  }
138
119
 
139
- keydown(event) {
120
+ async keydown(event) {
140
121
  switch (event.key) {
141
122
  case "ArrowDown":
142
123
  event.preventDefault()
@@ -154,12 +135,8 @@ export default class extends Controller {
154
135
  case "Enter":
155
136
  event.preventDefault()
156
137
  if (this.selectedIndex >= 0 && this.filteredOptions[this.selectedIndex]) {
157
- const opt = this.filteredOptions[this.selectedIndex]
158
- this.inputTarget.value = opt.value
159
- this.searchTarget.value = opt.label
160
- .replace(/^Create "/, "")
161
- .replace(/"$/, "")
162
- this.close()
138
+ const option = this.filteredOptions[this.selectedIndex]
139
+ await this.applyOption(option)
163
140
  }
164
141
  break
165
142
  case "Escape":
@@ -167,5 +144,111 @@ export default class extends Controller {
167
144
  break
168
145
  }
169
146
  }
147
+
148
+ hasExactOptionMatch(query) {
149
+ return this.filteredOptions.some((opt) => {
150
+ const value = String(opt.value || "").toLowerCase().trim()
151
+ const label = String(opt.label || "").toLowerCase().trim()
152
+ return value === query || label === query
153
+ })
154
+ }
155
+
156
+ buildCreateOption(query) {
157
+ return {
158
+ value: query,
159
+ label: `Create "${query}"`,
160
+ createLabel: query,
161
+ isNew: true,
162
+ }
163
+ }
164
+
165
+ optionFromElement(element) {
166
+ const isNew = element.dataset.isNew === "true"
167
+ return {
168
+ value: element.dataset.value,
169
+ label: isNew
170
+ ? element.dataset.createLabel || element.dataset.label
171
+ : element.dataset.label,
172
+ isNew,
173
+ }
174
+ }
175
+
176
+ async applyOption(option) {
177
+ if (!option) return
178
+
179
+ let value = option.value
180
+ let label = option.label || ""
181
+
182
+ if (option.isNew) {
183
+ if (!this.createUrlValue) {
184
+ this.showInlineError("Create URL is not configured for this field.")
185
+ return
186
+ }
187
+
188
+ const created = await this.createOption(label)
189
+ if (!created) return
190
+
191
+ value = created.id
192
+ label = created.name || created.title || label
193
+ }
194
+
195
+ this.inputTarget.value = value
196
+ this.searchTarget.value = label
197
+ this.close()
198
+ }
199
+
200
+ async createOption(label) {
201
+ const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content
202
+ this.searchTarget.disabled = true
203
+
204
+ try {
205
+ const response = await fetch(this.createUrlValue, {
206
+ method: "POST",
207
+ headers: {
208
+ "Content-Type": "application/json",
209
+ Accept: "application/json",
210
+ ...(csrfToken ? { "X-CSRF-Token": csrfToken } : {}),
211
+ },
212
+ body: JSON.stringify({
213
+ name: label,
214
+ title: label,
215
+ }),
216
+ })
217
+
218
+ const data = await response.json().catch(() => ({}))
219
+ if (!response.ok) {
220
+ const errors = Array.isArray(data?.errors) ? data.errors.join(", ") : null
221
+ this.showInlineError(errors || "Could not create this option.")
222
+ return null
223
+ }
224
+
225
+ if (!data?.id) {
226
+ this.showInlineError("Create endpoint returned no id.")
227
+ return null
228
+ }
229
+
230
+ return data
231
+ } catch (error) {
232
+ console.error("Create failed:", error)
233
+ this.showInlineError("Could not create this option.")
234
+ return null
235
+ } finally {
236
+ this.searchTarget.disabled = false
237
+ }
238
+ }
239
+
240
+ showInlineError(message) {
241
+ this.dropdownTarget.innerHTML = `
242
+ <div class="px-3 py-2 text-sm text-red-600">${this.escapeHtml(message)}</div>
243
+ `
244
+ this.dropdownTarget.classList.remove("hidden")
245
+ this.isOpen = true
246
+ }
247
+
248
+ escapeHtml(value) {
249
+ const div = document.createElement("div")
250
+ div.textContent = value
251
+ return div.innerHTML
252
+ }
170
253
  }
171
254
 
@@ -11,7 +11,8 @@ export default class extends Controller {
11
11
  }
12
12
 
13
13
  connect() {
14
- this.checked = this.inputTarget?.value === "1" || this.inputTarget?.value === "true"
14
+ const inputValue = this.hasInputTarget ? this.inputTarget.value : "0"
15
+ this.checked = inputValue === "1" || inputValue === "true"
15
16
  this.updateVisual()
16
17
  }
17
18
 
@@ -31,27 +32,27 @@ export default class extends Controller {
31
32
  }
32
33
 
33
34
  updateVisual() {
35
+ const inactiveTokens = this.classTokens(this.inactiveClasses)
36
+
34
37
  if (this.hasButtonTarget) {
35
38
  if (this.checked) {
36
- this.buttonTarget.classList.remove(...this.inactiveClasses.split(" "))
37
- this.buttonTarget.classList.add(this.activeClass)
39
+ if (inactiveTokens.length > 0) {
40
+ this.buttonTarget.classList.remove(...inactiveTokens)
41
+ }
42
+ if (this.activeClass) {
43
+ this.buttonTarget.classList.add(this.activeClass)
44
+ }
38
45
  } else {
39
- this.buttonTarget.classList.remove(this.activeClass)
40
- this.buttonTarget.classList.add(...this.inactiveClasses.split(" "))
46
+ if (this.activeClass) {
47
+ this.buttonTarget.classList.remove(this.activeClass)
48
+ }
49
+ if (inactiveTokens.length > 0) {
50
+ this.buttonTarget.classList.add(...inactiveTokens)
51
+ }
41
52
  }
42
53
  this.buttonTarget.setAttribute("aria-checked", this.checked.toString())
43
54
  }
44
55
 
45
- if (this.hasThumbTarget) {
46
- if (this.checked) {
47
- this.thumbTarget.classList.remove("translate-x-0")
48
- this.thumbTarget.classList.add("translate-x-5")
49
- } else {
50
- this.thumbTarget.classList.remove("translate-x-5")
51
- this.thumbTarget.classList.add("translate-x-0")
52
- }
53
- }
54
-
55
56
  if (this.hasLabelTarget) {
56
57
  this.labelTarget.textContent = this.checked ? "Enabled" : "Disabled"
57
58
  }
@@ -62,5 +63,13 @@ export default class extends Controller {
62
63
  this.inputTarget.value = this.checked ? "1" : "0"
63
64
  }
64
65
  }
66
+
67
+ classTokens(value) {
68
+ return value
69
+ .toString()
70
+ .split(/\s+/)
71
+ .map((token) => token.trim())
72
+ .filter((token) => token.length > 0)
73
+ }
65
74
  }
66
75
 
@@ -16,7 +16,7 @@
16
16
  </div>
17
17
 
18
18
  <!-- Form -->
19
- <div class="bg-white rounded-xl border border-slate-200 overflow-hidden">
19
+ <div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 overflow-visible">
20
20
  <div class="p-6">
21
21
  <%= render "admin_suite/shared/form", resource: @resource %>
22
22
  </div>
@@ -14,7 +14,7 @@
14
14
  </div>
15
15
 
16
16
  <!-- Form -->
17
- <div class="bg-white rounded-xl border border-slate-200 overflow-hidden">
17
+ <div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 overflow-visible">
18
18
  <div class="p-6">
19
19
  <%= render "admin_suite/shared/form", resource: @resource %>
20
20
  </div>
@@ -1,5 +1,5 @@
1
1
  <% if flash.any? %>
2
- <div class="fixed top-4 right-4 z-50 space-y-2 max-w-md">
2
+ <div class="fixed top-20 right-4 z-50 space-y-2 max-w-md" data-controller="admin-suite--flash">
3
3
  <% flash.each do |type, message| %>
4
4
  <% type = type.to_sym %>
5
5
  <% classes = case type
@@ -13,7 +13,10 @@
13
13
  "admin-suite-flash--info"
14
14
  end %>
15
15
 
16
- <div class="flex items-start gap-3 px-5 py-4 rounded-lg shadow-lg border <%= classes %>">
16
+ <div
17
+ data-admin-suite--flash-target="notification"
18
+ class="flex items-start gap-3 px-5 py-4 rounded-lg shadow-lg border <%= classes %>"
19
+ >
17
20
  <div class="flex-shrink-0 mt-0.5">
18
21
  <% icon_name = case type
19
22
  when :notice then "check-circle-2"
@@ -23,7 +26,17 @@
23
26
  end %>
24
27
  <%= admin_suite_icon(icon_name, class: "w-5 h-5") %>
25
28
  </div>
29
+
26
30
  <p class="flex-1 text-sm font-medium leading-snug"><%= message %></p>
31
+
32
+ <button
33
+ type="button"
34
+ class="flex-shrink-0 text-slate-400 hover:text-slate-600 transition-colors"
35
+ data-action="admin-suite--flash#dismiss"
36
+ aria-label="Dismiss notification"
37
+ >
38
+ <%= admin_suite_icon("x", class: "w-4 h-4") %>
39
+ </button>
27
40
  </div>
28
41
  <% end %>
29
42
  </div>
@@ -7,6 +7,7 @@
7
7
  %>
8
8
  <%
9
9
  value = record.public_send(field)
10
+ frame_id = dom_id(record, "#{field}_toggle")
10
11
  toggle_url ||= begin
11
12
  url_for(action: :toggle, id: record.id, field: field)
12
13
  rescue ActionController::UrlGenerationError
@@ -16,11 +17,12 @@
16
17
  end
17
18
  %>
18
19
 
19
- <%= turbo_frame_tag dom_id(record, :toggle), class: "admin-suite-toggle-wrap inline-flex align-middle" do %>
20
+ <%= turbo_frame_tag frame_id, class: "admin-suite-toggle-wrap inline-flex align-middle" do %>
20
21
  <% if toggle_url %>
21
22
  <%= button_to toggle_url,
22
23
  method: :post,
23
- form: { data: { turbo_frame: dom_id(record, :toggle) }, class: "m-0 inline-flex align-middle items-center" },
24
+ params: { field: field },
25
+ form: { data: { turbo_frame: frame_id }, class: "m-0 inline-flex align-middle items-center" },
24
26
  class: "admin-suite-toggle-track #{value ? 'is-on' : ''}" do %>
25
27
  <span class="sr-only">Toggle <%= field.to_s.humanize %></span>
26
28
  <span class="admin-suite-toggle-thumb"></span>
@@ -42,6 +42,19 @@
42
42
  <%= (admin_suite_actor&.respond_to?(:name) && admin_suite_actor.name.present?) ? admin_suite_actor.name.first.upcase : "A" %>
43
43
  </div>
44
44
  </div>
45
+
46
+ <% logout_path = admin_suite_logout_path %>
47
+ <% if logout_path.present? %>
48
+ <div class="pl-3 border-l border-slate-200">
49
+ <%= button_to logout_path,
50
+ method: admin_suite_logout_method,
51
+ form: { class: "inline-flex" },
52
+ class: "inline-flex items-center gap-1.5 px-3 py-2 rounded-lg text-sm font-medium text-slate-600 hover:text-red-600 hover:bg-red-50 transition-colors" do %>
53
+ <%= admin_suite_icon("log-out", class: "w-4 h-4") %>
54
+ <span class="hidden sm:inline"><%= admin_suite_logout_label %></span>
55
+ <% end %>
56
+ </div>
57
+ <% end %>
45
58
  </div>
46
59
  </div>
47
60
  </header>
@@ -29,6 +29,9 @@ These are the defaults in `AdminSuite::Configuration` / `AdminSuite::Engine`:
29
29
  - `authenticate`: `nil`
30
30
  - `current_actor`: `nil`
31
31
  - `authorize`: `nil`
32
+ - `logout_path`: `nil`
33
+ - `logout_method`: `:delete`
34
+ - `logout_label`: `"Log out"`
32
35
  - `resource_globs`: defaults to:
33
36
  - `Rails.root/config/admin_suite/resources/*.rb`
34
37
  - `Rails.root/app/admin/resources/*.rb`
@@ -97,6 +100,33 @@ Optional authorization hook (you can wire Pundit/CanCan/ActionPolicy/etc).
97
100
 
98
101
  Note: this hook is available, but your app must call it from resource definitions / custom actions as needed (AdminSuite will not guess your authorization policy).
99
102
 
103
+ ### `logout_path`
104
+
105
+ Optional sign-out action shown in the top bar.
106
+
107
+ - **Type**: `Proc`, `String`, `Symbol`, or `nil`
108
+ - **Proc signature**: `->(view_context) { ... }`
109
+
110
+ Example:
111
+
112
+ ```ruby
113
+ config.logout_path = ->(view) { view.main_app.internal_developer_logout_path }
114
+ ```
115
+
116
+ ### `logout_method`
117
+
118
+ HTTP method for the topbar sign-out button.
119
+
120
+ - **Type**: `Symbol` or `String`
121
+ - **Default**: `:delete`
122
+
123
+ ### `logout_label`
124
+
125
+ Button label for the topbar sign-out action.
126
+
127
+ - **Type**: `String` (or `Proc` for dynamic label)
128
+ - **Default**: `"Log out"`
129
+
100
130
  ### `resource_globs`
101
131
 
102
132
  Where AdminSuite should load resource definition files from.