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 +4 -4
- data/app/assets/admin_suite.css +1 -0
- data/app/controllers/admin_suite/resources_controller.rb +55 -20
- data/app/helpers/admin_suite/base_helper.rb +52 -2
- data/app/javascript/controllers/admin_suite/flash_controller.js +45 -0
- data/app/javascript/controllers/admin_suite/searchable_select_controller.js +121 -38
- data/app/javascript/controllers/admin_suite/toggle_switch_controller.js +24 -15
- data/app/views/admin_suite/resources/edit.html.erb +1 -1
- data/app/views/admin_suite/resources/new.html.erb +1 -1
- data/app/views/admin_suite/shared/_flash.html.erb +15 -2
- data/app/views/admin_suite/shared/_toggle_cell.html.erb +4 -2
- data/app/views/admin_suite/shared/_topbar.html.erb +13 -0
- data/docs/configuration.md +30 -0
- data/lib/admin_suite/configuration.rb +6 -0
- data/lib/admin_suite/ui/show_value_formatter.rb +30 -2
- data/lib/admin_suite/version.rb +1 -1
- data/lib/generators/admin_suite/install/templates/admin_suite.rb +5 -0
- metadata +7 -5
- data/test/dummy/log/test.log +0 -1512
- data/test/dummy/tmp/local_secret.txt +0 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ac7ab643c69ac46cf31a6b48734cd58cae60233e8284a738147b419e4982fde3
|
|
4
|
+
data.tar.gz: b37617cd8e1f00fef844f95ddb81800f12990d48940c1a7d14cf47de1fd7e64e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: d845685a1eabba91a371b975f3599bdf282a3940af720a9feb545751e044544a0aa8818611e742ebb37728fe86c5af41bb511462a211e9fcdc8e286649a67a05
|
|
7
|
+
data.tar.gz: dcc33dd1079fe5da4357445b939742d7cada340f858deabc45bdc3531dad59fe46a7c77c9ba33d9df5b0bc3ffe9f9c7b163a46b4484c3c99c5825e38f947acf9
|
data/app/assets/admin_suite.css
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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 =
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
|
219
|
+
return [] unless resource_config
|
|
222
220
|
|
|
223
|
-
resource_config.index_config
|
|
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-
|
|
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.
|
|
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.
|
|
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
|
|
130
|
-
|
|
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
|
|
158
|
-
this.
|
|
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
|
-
|
|
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
|
-
|
|
37
|
-
|
|
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
|
-
|
|
40
|
-
|
|
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-
|
|
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-
|
|
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-
|
|
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
|
|
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
|
|
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
|
-
|
|
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>
|
data/docs/configuration.md
CHANGED
|
@@ -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.
|