admin_suite 0.2.3 → 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: ed1253c0c2d8c5d658db9ad2bc569e0f0b2c76d8ed4741f3069d0e2240619848
4
- data.tar.gz: dc3dd2e0686581694f2d71f77b440bfe56606c867d4a7103e3d1148aecf5b3ed
3
+ metadata.gz: ac7ab643c69ac46cf31a6b48734cd58cae60233e8284a738147b419e4982fde3
4
+ data.tar.gz: b37617cd8e1f00fef844f95ddb81800f12990d48940c1a7d14cf47de1fd7e64e
5
5
  SHA512:
6
- metadata.gz: fb266215e70e3aa1aea7ff9e4ed49801c3960b7ef7c82c966f7e4417f0d7b3b84c88ca4a27b4fe2c1fdbc584932b78ce3bf7e621f885ec8ceacb3a48e7bb5d90
7
- data.tar.gz: 426c328efa276ac354a5776ab4636643747a2d8d4fc6849f789742ed03d3573b2fa14fbcc4451b5ba25c4f88ff95a546f3410c2d770b79e9d61dfc7195e14ec3
6
+ metadata.gz: d845685a1eabba91a371b975f3599bdf282a3940af720a9feb545751e044544a0aa8818611e742ebb37728fe86c5af41bb511462a211e9fcdc8e286649a67a05
7
+ data.tar.gz: dcc33dd1079fe5da4357445b939742d7cada340f858deabc45bdc3531dad59fe46a7c77c9ba33d9df5b0bc3ffe9f9c7b163a46b4484c3c99c5825e38f947acf9
@@ -145,23 +145,20 @@ module AdminSuite
145
145
  end
146
146
 
147
147
  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.
148
+ klass = resource_class
152
149
  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
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)
165
162
  end
166
163
 
167
164
  def resource
@@ -244,5 +241,33 @@ module AdminSuite
244
241
  def collection_url
245
242
  resources_path(portal: current_portal, resource_name: resource_name)
246
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
247
272
  end
248
273
  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
 
@@ -2,7 +2,7 @@
2
2
 
3
3
  module AdminSuite
4
4
  module Version
5
- VERSION = "0.2.3"
5
+ VERSION = "0.2.4"
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.4
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-18 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails