admin_suite 0.2.7 → 0.2.8

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: d5150c2ac986138055644b1e6f52c61ee774efc62e27a951a34d4ca9943fe7d8
4
- data.tar.gz: 57729f5a0e760f26111c7f73aeec78a7493a1b849ba12bd50c992ebfbaf6c034
3
+ metadata.gz: 663de764696e7dc2696e54b4bb43381fe83ee33b74b9d212269ff6d25433466b
4
+ data.tar.gz: 9e9c6f145e86d4c8a0d1087cdc2d45c752da1fbbe9f1d4219d7b8874ebcc5291
5
5
  SHA512:
6
- metadata.gz: e99ac448e983647cb570ae875e0681e138895c7783a6538ee6aa0b898e93001c3dd65b8dcf6b882885c83ef35042f55c29ef2e75f00d77025af6de58254be7b4
7
- data.tar.gz: df3a565311c4a10df7603eaa776bf1a3e4ed3f2cea6dc6445b3702986887c900f13c0af272af4bb006bb9af921b8d0ec545b8f049b12dbb2078cd77d69eddb2d
6
+ metadata.gz: b23ab5d74fe3658980016cb8e7df4c1fe09a87601590b98b09dd43f3ece53c3d40f6cbc159aec035daec52525b8dbdbdf0da34ad8846061465ec9de05a270ae4
7
+ data.tar.gz: b70e3d18ec3aba0acd78fea6ee74efbec8201546253483abd00f1443e3411e37c9f98b065df6cf04f51ec3df28d9fa5e4ad90738781119f616c3c71db44f6130
data/CHANGELOG.md CHANGED
@@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.2.8] - 2026-03-23
11
+
12
+ ### Added
13
+
14
+ - **Dependent searchable select** field type (`:dependent_select`) — cascading dropdowns where a child field's options filter by the selected parent value. Uses `parent_field:` option and `[label, value, group]` collection triples.
15
+ - **Stimulus entry point** (`admin_suite_application.js`) — automatically imports and registers all 12 AdminSuite Stimulus controllers on `window.Stimulus`.
16
+ - `parent_field` attribute on `FieldDefinition` for linking dependent fields.
17
+
18
+ ### Fixed
19
+
20
+ - **Stimulus controllers not loading** — engine controllers were pinned via importmap but never imported. Added explicit entry point in layout via `<script type="module">`.
21
+ - **Searchable select change propagation** — `applyOption()` now dispatches a `change` event on the hidden input, enabling dependent field chaining.
22
+
23
+ ### Security
24
+
25
+ - New `dependent_searchable_select_controller.js` uses safe DOM construction (`createElement`/`textContent`/`appendChild`) instead of `innerHTML` template literals.
26
+
10
27
  ## [0.2.6] - 2026-02-21
11
28
 
12
29
  ### Added
@@ -1117,6 +1117,62 @@ module AdminSuite
1117
1117
  end
1118
1118
  end
1119
1119
 
1120
+ def render_dependent_searchable_select(_f, field, resource)
1121
+ param_key = resource.class.model_name.param_key
1122
+ current_value = resource.public_send(field.name)
1123
+ collection = field.collection.is_a?(Proc) ? field.collection.call : field.collection
1124
+
1125
+ # Collection format: [[label, value, group], ...] — triples with group for filtering
1126
+ all_options_json = if collection.is_a?(Array)
1127
+ collection.map { |opt|
1128
+ if opt.is_a?(Array) && opt.size >= 3
1129
+ { value: opt[1], label: opt[0], group: opt[2] }
1130
+ elsif opt.is_a?(Array)
1131
+ { value: opt[1], label: opt[0] }
1132
+ else
1133
+ { value: opt, label: opt.to_s.humanize }
1134
+ end
1135
+ }.to_json
1136
+ else
1137
+ "[]"
1138
+ end
1139
+
1140
+ parent_selector = if field.parent_field
1141
+ "[name=\"#{param_key}[#{field.parent_field}]\"]"
1142
+ else
1143
+ ""
1144
+ end
1145
+
1146
+ current_label = if current_value.present? && collection.is_a?(Array)
1147
+ match = collection.find { |opt| opt.is_a?(Array) ? opt[1].to_s == current_value.to_s : opt.to_s == current_value.to_s }
1148
+ match.is_a?(Array) ? match[0] : match.to_s
1149
+ else
1150
+ current_value
1151
+ end
1152
+
1153
+ content_tag(:div,
1154
+ data: {
1155
+ controller: "admin-suite--dependent-searchable-select",
1156
+ "admin-suite--dependent-searchable-select-all-options-value": all_options_json,
1157
+ "admin-suite--dependent-searchable-select-parent-selector-value": parent_selector
1158
+ },
1159
+ class: "relative") do
1160
+ concat(hidden_field_tag("#{param_key}[#{field.name}]", current_value,
1161
+ data: { "admin-suite--dependent-searchable-select-target": "input" }))
1162
+ concat(text_field_tag(nil, current_label,
1163
+ class: "form-input w-full",
1164
+ placeholder: field.placeholder || "Search...",
1165
+ autocomplete: "off",
1166
+ data: {
1167
+ "admin-suite--dependent-searchable-select-target": "search",
1168
+ action: "input->admin-suite--dependent-searchable-select#search focus->admin-suite--dependent-searchable-select#open keydown->admin-suite--dependent-searchable-select#keydown"
1169
+ }))
1170
+ concat(content_tag(:div, "",
1171
+ 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",
1172
+ data: { "admin-suite--dependent-searchable-select-target": "dropdown" }))
1173
+ end
1174
+ end
1175
+
1120
1176
  def render_multi_select(_f, field, resource)
1121
1177
  param_key = resource.class.model_name.param_key
1122
1178
  current_values =
@@ -0,0 +1,44 @@
1
+ import { Application } from "@hotwired/stimulus"
2
+
3
+ // Reuse the host app's Stimulus application if available, otherwise start a new one
4
+ const application = window.Stimulus || Application.start()
5
+ window.Stimulus ||= application
6
+
7
+ import SearchableSelectController from "controllers/admin_suite/searchable_select_controller"
8
+ application.register("admin-suite--searchable-select", SearchableSelectController)
9
+
10
+ import ToggleSwitchController from "controllers/admin_suite/toggle_switch_controller"
11
+ application.register("admin-suite--toggle-switch", ToggleSwitchController)
12
+
13
+ import TagSelectController from "controllers/admin_suite/tag_select_controller"
14
+ application.register("admin-suite--tag-select", TagSelectController)
15
+
16
+ import FileUploadController from "controllers/admin_suite/file_upload_controller"
17
+ application.register("admin-suite--file-upload", FileUploadController)
18
+
19
+ import MarkdownEditorController from "controllers/admin_suite/markdown_editor_controller"
20
+ application.register("admin-suite--markdown-editor", MarkdownEditorController)
21
+
22
+ import JsonEditorController from "controllers/admin_suite/json_editor_controller"
23
+ application.register("admin-suite--json-editor", JsonEditorController)
24
+
25
+ import CodeEditorController from "controllers/admin_suite/code_editor_controller"
26
+ application.register("admin-suite--code-editor", CodeEditorController)
27
+
28
+ import LiveFilterController from "controllers/admin_suite/live_filter_controller"
29
+ application.register("admin-suite--live-filter", LiveFilterController)
30
+
31
+ import ClickActionsController from "controllers/admin_suite/click_actions_controller"
32
+ application.register("admin-suite--click-actions", ClickActionsController)
33
+
34
+ import ClipboardController from "controllers/admin_suite/clipboard_controller"
35
+ application.register("admin-suite--clipboard", ClipboardController)
36
+
37
+ import SidebarController from "controllers/admin_suite/sidebar_controller"
38
+ application.register("admin-suite--sidebar", SidebarController)
39
+
40
+ import FlashController from "controllers/admin_suite/flash_controller"
41
+ application.register("admin-suite--flash", FlashController)
42
+
43
+ import DependentSearchableSelectController from "controllers/admin_suite/dependent_searchable_select_controller"
44
+ application.register("admin-suite--dependent-searchable-select", DependentSearchableSelectController)
@@ -0,0 +1,228 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ /**
4
+ * Dependent Searchable Select Controller (Admin Suite)
5
+ *
6
+ * Extends searchable-select behavior with parent-field filtering.
7
+ * Options are filtered by `group` matching the parent field's current value.
8
+ *
9
+ * Usage:
10
+ * data-controller="admin-suite--dependent-searchable-select"
11
+ * data-admin-suite--dependent-searchable-select-all-options-value="<%= options.to_json %>"
12
+ * data-admin-suite--dependent-searchable-select-parent-selector-value="#parent_field_id"
13
+ *
14
+ * Each option in allOptions should have: { value, label, group }
15
+ * The `group` field is matched against the parent field's value.
16
+ */
17
+ export default class extends Controller {
18
+ static targets = ["input", "search", "dropdown"]
19
+ static values = {
20
+ allOptions: { type: Array, default: [] },
21
+ parentSelector: { type: String, default: "" },
22
+ }
23
+
24
+ connect() {
25
+ this.isOpen = false
26
+ this.selectedIndex = -1
27
+ this.parentFilteredOptions = [...this.allOptionsValue]
28
+
29
+ this.clickOutside = this.clickOutside.bind(this)
30
+ document.addEventListener("click", this.clickOutside)
31
+
32
+ this.bindParent()
33
+ }
34
+
35
+ disconnect() {
36
+ document.removeEventListener("click", this.clickOutside)
37
+
38
+ if (this._parentField && this._onParentChange) {
39
+ this._parentField.removeEventListener("change", this._onParentChange)
40
+ }
41
+ }
42
+
43
+ bindParent() {
44
+ const selector = this.parentSelectorValue
45
+ if (!selector) return
46
+
47
+ this._parentField = document.querySelector(selector)
48
+ if (!this._parentField) return
49
+
50
+ this._onParentChange = this.onParentChange.bind(this)
51
+ this._parentField.addEventListener("change", this._onParentChange)
52
+
53
+ // Apply initial filter if parent already has a value
54
+ if (this._parentField.value) {
55
+ this.filterByParent(this._parentField.value)
56
+ }
57
+ }
58
+
59
+ onParentChange() {
60
+ const parentValue = this._parentField ? this._parentField.value : ""
61
+ this.filterByParent(parentValue)
62
+
63
+ // Clear current selection if it no longer belongs to the new parent group
64
+ const currentValue = this.inputTarget.value
65
+ if (currentValue) {
66
+ const stillValid = this.parentFilteredOptions.some(
67
+ (opt) => String(opt.value) === String(currentValue),
68
+ )
69
+ if (!stillValid) {
70
+ this.inputTarget.value = ""
71
+ this.searchTarget.value = ""
72
+ this.inputTarget.dispatchEvent(new Event("change", { bubbles: true }))
73
+ }
74
+ }
75
+
76
+ if (this.isOpen) {
77
+ this.renderDropdown()
78
+ }
79
+ }
80
+
81
+ filterByParent(parentValue) {
82
+ if (!parentValue) {
83
+ this.parentFilteredOptions = [...this.allOptionsValue]
84
+ } else {
85
+ this.parentFilteredOptions = this.allOptionsValue.filter(
86
+ (opt) => String(opt.group) === String(parentValue),
87
+ )
88
+ }
89
+ }
90
+
91
+ open() {
92
+ this.isOpen = true
93
+ this.filteredOptions = [...this.parentFilteredOptions]
94
+ this.renderDropdown()
95
+ this.dropdownTarget.classList.remove("hidden")
96
+ }
97
+
98
+ close() {
99
+ this.isOpen = false
100
+ this.dropdownTarget.classList.add("hidden")
101
+ this.selectedIndex = -1
102
+ }
103
+
104
+ clickOutside(event) {
105
+ if (!this.element.contains(event.target)) {
106
+ this.close()
107
+ }
108
+ }
109
+
110
+ search() {
111
+ const query = this.searchTarget.value.toLowerCase().trim()
112
+
113
+ if (query) {
114
+ this.filteredOptions = this.parentFilteredOptions.filter((opt) =>
115
+ opt.label.toLowerCase().includes(query),
116
+ )
117
+ } else {
118
+ this.filteredOptions = [...this.parentFilteredOptions]
119
+ }
120
+
121
+ this.renderDropdown()
122
+
123
+ if (!this.isOpen) this.open()
124
+ }
125
+
126
+ select(event) {
127
+ const btn = event.currentTarget
128
+ const value = btn.dataset.value
129
+ const label = btn.dataset.label
130
+
131
+ this.inputTarget.value = value
132
+ this.searchTarget.value = label
133
+ this.close()
134
+ this.inputTarget.dispatchEvent(new Event("change", { bubbles: true }))
135
+ }
136
+
137
+ keydown(event) {
138
+ const opts = this.filteredOptions || this.parentFilteredOptions
139
+
140
+ switch (event.key) {
141
+ case "ArrowDown":
142
+ event.preventDefault()
143
+ if (!this.isOpen) {
144
+ this.filteredOptions = [...this.parentFilteredOptions]
145
+ this.open()
146
+ break
147
+ }
148
+ this.selectedIndex = Math.min(this.selectedIndex + 1, opts.length - 1)
149
+ this.renderDropdown()
150
+ break
151
+ case "ArrowUp":
152
+ event.preventDefault()
153
+ this.selectedIndex = Math.max(this.selectedIndex - 1, 0)
154
+ this.renderDropdown()
155
+ break
156
+ case "Enter":
157
+ event.preventDefault()
158
+ if (this.selectedIndex >= 0 && opts[this.selectedIndex]) {
159
+ const opt = opts[this.selectedIndex]
160
+ this.inputTarget.value = opt.value
161
+ this.searchTarget.value = opt.label
162
+ this.close()
163
+ this.inputTarget.dispatchEvent(new Event("change", { bubbles: true }))
164
+ }
165
+ break
166
+ case "Escape":
167
+ this.close()
168
+ break
169
+ }
170
+ }
171
+
172
+ renderDropdown() {
173
+ const opts = this.filteredOptions || this.parentFilteredOptions
174
+
175
+ // Clear existing content using safe DOM manipulation (avoids innerHTML XSS risk)
176
+ while (this.dropdownTarget.firstChild) {
177
+ this.dropdownTarget.removeChild(this.dropdownTarget.firstChild)
178
+ }
179
+
180
+ if (!opts.length) {
181
+ const empty = document.createElement("div")
182
+ empty.className = "px-3 py-2 text-sm text-slate-400"
183
+ empty.textContent = "No results found"
184
+ this.dropdownTarget.appendChild(empty)
185
+ return
186
+ }
187
+
188
+ opts.forEach((opt, index) => {
189
+ const btn = document.createElement("button")
190
+ btn.type = "button"
191
+ btn.className = [
192
+ "block w-full text-left px-3 py-2 text-sm hover:bg-slate-100",
193
+ index === this.selectedIndex ? "bg-slate-100" : "",
194
+ "text-slate-700",
195
+ ]
196
+ .filter(Boolean)
197
+ .join(" ")
198
+ btn.dataset.action =
199
+ "click->admin-suite--dependent-searchable-select#select"
200
+ btn.dataset.value = this.escapeAttr(String(opt.value))
201
+ btn.dataset.label = this.escapeAttr(String(opt.label))
202
+
203
+ // Label text node
204
+ btn.appendChild(document.createTextNode(opt.label))
205
+
206
+ // Optional group badge
207
+ if (opt.group) {
208
+ const badge = document.createElement("span")
209
+ badge.className =
210
+ "ml-2 text-xs text-slate-400 font-normal"
211
+ badge.textContent = opt.group
212
+ btn.appendChild(badge)
213
+ }
214
+
215
+ this.dropdownTarget.appendChild(btn)
216
+ })
217
+ }
218
+
219
+ // Escapes a string for use in HTML data attributes
220
+ escapeAttr(value) {
221
+ return String(value)
222
+ .replace(/&/g, "&amp;")
223
+ .replace(/"/g, "&quot;")
224
+ .replace(/'/g, "&#39;")
225
+ .replace(/</g, "&lt;")
226
+ .replace(/>/g, "&gt;")
227
+ }
228
+ }
@@ -195,6 +195,7 @@ export default class extends Controller {
195
195
  this.inputTarget.value = value
196
196
  this.searchTarget.value = label
197
197
  this.close()
198
+ this.inputTarget.dispatchEvent(new Event("change", { bubbles: true }))
198
199
  }
199
200
 
200
201
  async createOption(label) {
@@ -35,6 +35,7 @@
35
35
 
36
36
  <% if respond_to?(:javascript_importmap_tags) %>
37
37
  <%= javascript_importmap_tags %>
38
+ <%= javascript_import_module_tag "admin_suite_application" %>
38
39
  <% end %>
39
40
  </head>
40
41
 
data/config/importmap.rb CHANGED
@@ -1 +1,2 @@
1
+ pin "admin_suite_application", to: "admin_suite_application.js"
1
2
  pin_all_from File.expand_path("../app/javascript/controllers", __dir__), under: "controllers"
@@ -380,7 +380,8 @@ module Admin
380
380
  preview: options[:preview] != false,
381
381
  variants: options[:variants],
382
382
  label_color: options[:label_color],
383
- label_size: options[:label_size]
383
+ label_size: options[:label_size],
384
+ parent_field: options[:parent_field]
384
385
  )
385
386
  end
386
387
 
@@ -406,7 +407,7 @@ module Admin
406
407
  :name, :type, :required, :label, :help, :placeholder,
407
408
  :collection, :create_url, :accept, :rows, :readonly,
408
409
  :if_condition, :unless_condition, :multiple, :creatable,
409
- :preview, :variants, :label_color, :label_size,
410
+ :preview, :variants, :label_color, :label_size, :parent_field,
410
411
  keyword_init: true
411
412
  )
412
413
 
@@ -58,6 +58,10 @@ AdminSuite::UI::FieldRendererRegistry.register(:searchable_select) do |view, f,
58
58
  view.render_searchable_select(f, field, resource)
59
59
  end
60
60
 
61
+ AdminSuite::UI::FieldRendererRegistry.register(:dependent_select) do |view, f, field, resource, _field_class|
62
+ view.render_dependent_searchable_select(f, field, resource)
63
+ end
64
+
61
65
  AdminSuite::UI::FieldRendererRegistry.register(:multi_select) do |view, f, field, resource, _field_class|
62
66
  view.render_multi_select(f, field, resource)
63
67
  end
@@ -2,7 +2,7 @@
2
2
 
3
3
  module AdminSuite
4
4
  module Version
5
- VERSION = "0.2.7"
5
+ VERSION = "0.2.8"
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.7
4
+ version: 0.2.8
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-28 00:00:00.000000000 Z
11
+ date: 2026-03-23 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -136,9 +136,11 @@ files:
136
136
  - app/helpers/admin_suite/panels_helper.rb
137
137
  - app/helpers/admin_suite/resources_helper.rb
138
138
  - app/helpers/admin_suite/theme_helper.rb
139
+ - app/javascript/admin_suite_application.js
139
140
  - app/javascript/controllers/admin_suite/click_actions_controller.js
140
141
  - app/javascript/controllers/admin_suite/clipboard_controller.js
141
142
  - app/javascript/controllers/admin_suite/code_editor_controller.js
143
+ - app/javascript/controllers/admin_suite/dependent_searchable_select_controller.js
142
144
  - app/javascript/controllers/admin_suite/file_upload_controller.js
143
145
  - app/javascript/controllers/admin_suite/flash_controller.js
144
146
  - app/javascript/controllers/admin_suite/json_editor_controller.js