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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ed1253c0c2d8c5d658db9ad2bc569e0f0b2c76d8ed4741f3069d0e2240619848
4
- data.tar.gz: dc3dd2e0686581694f2d71f77b440bfe56606c867d4a7103e3d1148aecf5b3ed
3
+ metadata.gz: 3679b3f1b7a2f81d3001c2def3f283c0af6af3ab3c8d593c552fd073f72aaff1
4
+ data.tar.gz: f4087201617ce4d248c4c7727cc7464344e43fed12a50c5a3eb966e86e0d5c61
5
5
  SHA512:
6
- metadata.gz: fb266215e70e3aa1aea7ff9e4ed49801c3960b7ef7c82c966f7e4417f0d7b3b84c88ca4a27b4fe2c1fdbc584932b78ce3bf7e621f885ec8ceacb3a48e7bb5d90
7
- data.tar.gz: 426c328efa276ac354a5776ab4636643747a2d8d4fc6849f789742ed03d3573b2fa14fbcc4451b5ba25c4f88ff95a546f3410c2d770b79e9d61dfc7195e14ec3
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 resource_url(@resource), notice: result.message
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
- @resource = resource_class.find(params[:id])
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 = resource_class.column_names
154
-
155
- @resource =
156
- if columns.include?("slug")
157
- resource_class.find_by!(slug: id)
158
- elsif columns.include?("uuid")
159
- resource_class.find_by!(uuid: id)
160
- elsif columns.include?("token")
161
- resource_class.find_by!(token: id)
162
- else
163
- raise
164
- end
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
- key = resource_class.model_name.param_key
218
- params.require(key).permit(permitted_fields + array_fields)
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.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
 
@@ -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
- success_result("#{action.label} completed successfully")
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)
@@ -2,7 +2,7 @@
2
2
 
3
3
  module AdminSuite
4
4
  module Version
5
- VERSION = "0.2.3"
5
+ VERSION = "0.2.5"
6
6
  end
7
7
 
8
8
  # Backward-compatible constant.
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.3
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-14 00:00:00.000000000 Z
11
+ date: 2026-02-19 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails