admin_suite 0.2.3 → 0.2.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 +4 -4
- data/app/controllers/admin_suite/resources_controller.rb +55 -19
- data/app/helpers/admin_suite/base_helper.rb +12 -1
- data/app/javascript/controllers/admin_suite/searchable_select_controller.js +121 -38
- data/lib/admin/base/action_executor.rb +21 -2
- data/lib/admin_suite/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3679b3f1b7a2f81d3001c2def3f283c0af6af3ab3c8d593c552fd073f72aaff1
|
|
4
|
+
data.tar.gz: f4087201617ce4d248c4c7727cc7464344e43fed12a50c5a3eb966e86e0d5c61
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c303e1f7222b79e6a0dba629b08657078c104beb661338f721285462aabc3319e2546d6a3549921eb648f079d527c42d05954e4dd22f6455ec3c783ddbb9d92f
|
|
7
|
+
data.tar.gz: 21dbc5ae2cd26045afa6f9dfc0a0852fdb82470ee359fbb18236c655e45ca836301d178e8c9e4ad6fab018c3f95286d528152e2426b5cb6d28f6dcb9c07be0bf
|
|
@@ -64,9 +64,10 @@ module AdminSuite
|
|
|
64
64
|
|
|
65
65
|
executor = Admin::Base::ActionExecutor.new(resource_config, action, admin_suite_actor)
|
|
66
66
|
result = executor.execute_member(@resource, params.to_unsafe_h)
|
|
67
|
+
redirect_target = result.redirect_url.presence || resource_url(@resource)
|
|
67
68
|
|
|
68
69
|
if result.success?
|
|
69
|
-
redirect_to
|
|
70
|
+
redirect_to redirect_target, notice: result.message
|
|
70
71
|
else
|
|
71
72
|
redirect_to resource_url(@resource), alert: result.message
|
|
72
73
|
end
|
|
@@ -145,23 +146,20 @@ module AdminSuite
|
|
|
145
146
|
end
|
|
146
147
|
|
|
147
148
|
def set_resource
|
|
148
|
-
|
|
149
|
-
rescue ActiveRecord::RecordNotFound
|
|
150
|
-
# Support "friendly" params (e.g. slugged records) without requiring host apps
|
|
151
|
-
# to change their model primary keys.
|
|
149
|
+
klass = resource_class
|
|
152
150
|
id = params[:id].to_s
|
|
153
|
-
columns =
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
151
|
+
columns = klass.column_names
|
|
152
|
+
|
|
153
|
+
# Prevent ActiveRecord from coercing UUID-ish params like "2ce3-..."
|
|
154
|
+
# into integer ids (e.g., 2) for integer primary keys.
|
|
155
|
+
if non_numeric_id_for_numeric_primary_key?(klass, id)
|
|
156
|
+
@resource = find_friendly_resource!(klass, id, columns)
|
|
157
|
+
return
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
@resource = klass.find(id)
|
|
161
|
+
rescue ActiveRecord::RecordNotFound
|
|
162
|
+
@resource = find_friendly_resource!(klass, id, columns)
|
|
165
163
|
end
|
|
166
164
|
|
|
167
165
|
def resource
|
|
@@ -214,8 +212,18 @@ module AdminSuite
|
|
|
214
212
|
end
|
|
215
213
|
end
|
|
216
214
|
|
|
217
|
-
|
|
218
|
-
|
|
215
|
+
candidate_keys = []
|
|
216
|
+
candidate_keys << @resource.class.model_name.param_key if defined?(@resource) && @resource.present?
|
|
217
|
+
candidate_keys << resource_class.model_name.param_key
|
|
218
|
+
candidate_keys.uniq!
|
|
219
|
+
|
|
220
|
+
candidate_keys.each do |key|
|
|
221
|
+
if params.key?(key) || params.key?(key.to_sym)
|
|
222
|
+
return params.require(key).permit(permitted_fields + array_fields)
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
params.require(resource_class.model_name.param_key).permit(permitted_fields + array_fields)
|
|
219
227
|
end
|
|
220
228
|
|
|
221
229
|
def toggleable_fields
|
|
@@ -244,5 +252,33 @@ module AdminSuite
|
|
|
244
252
|
def collection_url
|
|
245
253
|
resources_path(portal: current_portal, resource_name: resource_name)
|
|
246
254
|
end
|
|
255
|
+
|
|
256
|
+
def find_friendly_resource!(klass, id, columns = klass.column_names)
|
|
257
|
+
if columns.include?("slug")
|
|
258
|
+
record = klass.find_by(slug: id)
|
|
259
|
+
return record if record
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
if columns.include?("uuid")
|
|
263
|
+
record = klass.find_by(uuid: id)
|
|
264
|
+
return record if record
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
if columns.include?("token")
|
|
268
|
+
record = klass.find_by(token: id)
|
|
269
|
+
return record if record
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
raise ActiveRecord::RecordNotFound
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
def non_numeric_id_for_numeric_primary_key?(klass, id)
|
|
276
|
+
primary_key = klass.primary_key.to_s
|
|
277
|
+
return false if primary_key.blank?
|
|
278
|
+
|
|
279
|
+
pk_type = klass.columns_hash[primary_key]&.type
|
|
280
|
+
numeric_pk = %i[integer bigint].include?(pk_type)
|
|
281
|
+
numeric_pk && id !~ /\A\d+\z/
|
|
282
|
+
end
|
|
247
283
|
end
|
|
248
284
|
end
|
|
@@ -1079,6 +1079,16 @@ module AdminSuite
|
|
|
1079
1079
|
current_label = if current_value.present? && collection.is_a?(Array)
|
|
1080
1080
|
match = collection.find { |opt| opt.is_a?(Array) ? opt[1].to_s == current_value.to_s : opt.to_s == current_value.to_s }
|
|
1081
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
|
|
1082
1092
|
else
|
|
1083
1093
|
current_value
|
|
1084
1094
|
end
|
|
@@ -1088,7 +1098,8 @@ module AdminSuite
|
|
|
1088
1098
|
controller: "admin-suite--searchable-select",
|
|
1089
1099
|
"admin-suite--searchable-select-options-value": options_json,
|
|
1090
1100
|
"admin-suite--searchable-select-creatable-value": field.create_url.present?,
|
|
1091
|
-
"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
|
|
1092
1103
|
},
|
|
1093
1104
|
class: "relative") do
|
|
1094
1105
|
concat(hidden_field_tag("#{param_key}[#{field.name}]", current_value, data: { "admin-suite--searchable-select-target": "input" }))
|
|
@@ -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
|
|
|
@@ -109,14 +109,33 @@ module Admin
|
|
|
109
109
|
|
|
110
110
|
def execute_model_method(record, action, bang: false)
|
|
111
111
|
method_name = bang ? "#{action.name}!" : action.name
|
|
112
|
-
record.public_send(method_name)
|
|
113
|
-
|
|
112
|
+
action_result = record.public_send(method_name)
|
|
113
|
+
return action_result if action_result.is_a?(Result)
|
|
114
|
+
|
|
115
|
+
success_result(
|
|
116
|
+
"#{action.label} completed successfully",
|
|
117
|
+
redirect_url: redirect_url_for_action(action, action_result)
|
|
118
|
+
)
|
|
114
119
|
rescue ActiveRecord::RecordInvalid => e
|
|
115
120
|
failure_result("Validation failed: #{e.record.errors.full_messages.join(', ')}")
|
|
116
121
|
rescue AASM::InvalidTransition => e
|
|
117
122
|
failure_result("Invalid state transition: #{e.message}")
|
|
118
123
|
end
|
|
119
124
|
|
|
125
|
+
def redirect_url_for_action(action, action_result)
|
|
126
|
+
return nil unless action.name.to_sym == :duplicate
|
|
127
|
+
return nil unless action_result.respond_to?(:persisted?) && action_result.persisted?
|
|
128
|
+
return nil unless resource_class.respond_to?(:portal_name) && resource_class.respond_to?(:resource_name_plural)
|
|
129
|
+
|
|
130
|
+
AdminSuite::Engine.routes.url_helpers.resource_path(
|
|
131
|
+
portal: resource_class.portal_name,
|
|
132
|
+
resource_name: resource_class.resource_name_plural,
|
|
133
|
+
id: action_result.to_param
|
|
134
|
+
)
|
|
135
|
+
rescue StandardError
|
|
136
|
+
nil
|
|
137
|
+
end
|
|
138
|
+
|
|
120
139
|
def find_handler_class(action)
|
|
121
140
|
if defined?(AdminSuite) && AdminSuite.config.resolve_action_handler.present?
|
|
122
141
|
resolved = AdminSuite.config.resolve_action_handler.call(resource_class, action.name)
|
data/lib/admin_suite/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: admin_suite
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.2.
|
|
4
|
+
version: 0.2.5
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- TechWright Labs
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-02-
|
|
11
|
+
date: 2026-02-19 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: rails
|