admin_suite 0.2.7 → 0.2.9

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: f50f5a24977b7bc8284ee051fb3d9d1746b49a1ab8d9ade00d917bf0a8ff412b
4
+ data.tar.gz: 6e32362f8fe763980b83fa34910b21020d7bdca7918afb5d63084aae7e80d72e
5
5
  SHA512:
6
- metadata.gz: e99ac448e983647cb570ae875e0681e138895c7783a6538ee6aa0b898e93001c3dd65b8dcf6b882885c83ef35042f55c29ef2e75f00d77025af6de58254be7b4
7
- data.tar.gz: df3a565311c4a10df7603eaa776bf1a3e4ed3f2cea6dc6445b3702986887c900f13c0af272af4bb006bb9af921b8d0ec545b8f049b12dbb2078cd77d69eddb2d
6
+ metadata.gz: b980579e952dc4ec9cd1cd56dab61219f9320e810df0697a97ad42031e08fa59793c893adfdcd00e7d0ab7449468ae7d02a886f2dd6a3406c65bf5c750a28c3f
7
+ data.tar.gz: 603fdf2522093c9aba2427286e6573515a75d3016367dde07d3a74661bc7e15ffe95b1b388258c3398cc4d6583b85ef7c9ace18cc55e118b116344835802dfdc
data/CHANGELOG.md CHANGED
@@ -7,6 +7,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.2.9] - 2026-07-14
11
+
12
+ ### Added
13
+
14
+ - Read-only resource support, server-side filter defaults, and filtered-scope statistics.
15
+
16
+ ## [0.2.8] - 2026-03-23
17
+
18
+ ### Added
19
+
20
+ - **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.
21
+ - **Stimulus entry point** (`admin_suite_application.js`) — automatically imports and registers all 12 AdminSuite Stimulus controllers on `window.Stimulus`.
22
+ - `parent_field` attribute on `FieldDefinition` for linking dependent fields.
23
+
24
+ ### Fixed
25
+
26
+ - **Stimulus controllers not loading** — engine controllers were pinned via importmap but never imported. Added explicit entry point in layout via `<script type="module">`.
27
+ - **Searchable select change propagation** — `applyOption()` now dispatches a `change` event on the hidden input, enabling dependent field chaining.
28
+
29
+ ### Security
30
+
31
+ - New `dependent_searchable_select_controller.js` uses safe DOM construction (`createElement`/`textContent`/`appendChild`) instead of `innerHTML` template literals.
32
+
10
33
  ## [0.2.6] - 2026-02-21
11
34
 
12
35
  ### Added
@@ -21,4 +44,3 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
21
44
  - Initial extraction of the AdminSuite Rails engine.
22
45
  - Resource/portal DSL, docs viewer, and theming primitives.
23
46
  - Isolated gem test suite with a dummy Rails app.
24
-
data/CONTRIBUTING.md CHANGED
@@ -12,7 +12,7 @@ Thank you for your interest in contributing to AdminSuite!
12
12
 
13
13
  ## Development
14
14
 
15
- See `docs/development.md` for detailed information on:
15
+ See `../_vault/products/admin_suite/docs/development.md` for detailed information on:
16
16
  - Setting up your development environment
17
17
  - Running tests
18
18
  - Code style guidelines
@@ -36,7 +36,7 @@ The CI workflow runs automatically on every pull request.
36
36
 
37
37
  ## Releasing
38
38
 
39
- See `docs/releasing.md` for information on how releases are managed.
39
+ See `../_vault/products/admin_suite/docs/releasing.md` for information on how releases are managed.
40
40
 
41
41
  Releases are automated via GitHub Actions when changes are merged to `main` with a version bump.
42
42
 
data/README.md CHANGED
@@ -15,21 +15,9 @@ across other products.
15
15
 
16
16
  ## Documentation
17
17
 
18
- Start here:
19
-
20
- - `docs/README.md` (index)
21
-
22
- Read more:
23
-
24
- - `docs/installation.md`
25
- - `docs/configuration.md`
26
- - `docs/portals.md`
27
- - `docs/resources.md`
28
- - `docs/fields.md`
29
- - `docs/actions.md`
30
- - `docs/theming.md`
31
- - `docs/docs_viewer.md`
32
- - `docs/troubleshooting.md`
18
+ Canonical AdminSuite documentation lives in the TechWright vault at
19
+ `../_vault/products/admin_suite/docs/`. This repo intentionally has no root
20
+ `docs/` tree or docs symlink.
33
21
 
34
22
  ## Quickstart
35
23
 
@@ -67,7 +55,7 @@ AdminSuite.configure do |config|
67
55
  end
68
56
  ```
69
57
 
70
- Read more: `docs/configuration.md`
58
+ Read more: `../_vault/products/admin_suite/docs/configuration.md`
71
59
 
72
60
  ### Add portals (navigation metadata)
73
61
 
@@ -80,7 +68,7 @@ AdminSuite.configure do |config|
80
68
  end
81
69
  ```
82
70
 
83
- Read more: `docs/portals.md`
71
+ Read more: `../_vault/products/admin_suite/docs/portals.md`
84
72
 
85
73
  ### Add a resource
86
74
 
@@ -119,25 +107,23 @@ module Admin
119
107
  end
120
108
  ```
121
109
 
122
- Read more: `docs/resources.md` and `docs/fields.md`
110
+ Read more: `../_vault/products/admin_suite/docs/resources.md` and `../_vault/products/admin_suite/docs/fields.md`
123
111
 
124
112
  ### Add docs (optional)
125
113
 
126
- Create markdown files in your host app:
127
-
128
- - `docs/*.md` (or set `config.docs_path`)
114
+ Set `config.docs_path` to an explicit documentation source. TechWright host apps point this at their canonical `_vault/products/<product>/docs/` directory; they do not create repo `docs/` trees.
129
115
 
130
116
  Then visit:
131
117
 
132
118
  - `/internal/admin/docs`
133
119
 
134
- Read more: `docs/docs_viewer.md`
120
+ Read more: `../_vault/products/admin_suite/docs/docs_viewer.md`
135
121
 
136
122
  ## Contributing
137
123
 
138
124
  See:
139
125
 
140
126
  - `CONTRIBUTING.md`
141
- - `docs/development.md`
142
- - `docs/releasing.md`
127
+ - `../_vault/products/admin_suite/docs/development.md`
128
+ - `../_vault/products/admin_suite/docs/releasing.md`
143
129
 
@@ -5,14 +5,16 @@ module AdminSuite
5
5
  include Pagy::Backend
6
6
  include Pagy::Frontend
7
7
 
8
+ before_action :enforce_read_only!, only: %i[new create edit update destroy]
8
9
  before_action :set_resource, if: -> { params[:id].present? && !%w[index new create].include?(action_name) }
9
10
 
10
11
  helper_method :resource_config, :resource_class, :resource, :collection, :current_portal, :resource_name
11
12
 
12
13
  # GET /:portal/:resource_name
13
14
  def index
14
- @stats = calculate_stats if resource_config&.index_config&.stats_list&.any?
15
- @pagy, @collection = paginate_collection(filtered_collection)
15
+ scope = filtered_collection
16
+ @stats = calculate_stats(scope) if resource_config&.index_config&.stats_list&.any?
17
+ @pagy, @collection = paginate_collection(scope)
16
18
  end
17
19
 
18
20
  # GET /:portal/:resource_name/:id
@@ -181,11 +183,15 @@ module AdminSuite
181
183
  pagy(scope, items: per_page)
182
184
  end
183
185
 
184
- def calculate_stats
186
+ def calculate_stats(scope)
185
187
  resource_config.index_config.stats_list.map do |stat_def|
186
188
  value =
187
189
  begin
188
- stat_def.calculator.call
190
+ if stat_def.calculator.arity.zero?
191
+ stat_def.calculator.call
192
+ else
193
+ stat_def.calculator.call(scope)
194
+ end
189
195
  rescue StandardError
190
196
  "N/A"
191
197
  end
@@ -193,6 +199,10 @@ module AdminSuite
193
199
  end
194
200
  end
195
201
 
202
+ def enforce_read_only!
203
+ head :not_found if resource_config&.read_only?
204
+ end
205
+
196
206
  def find_action(name)
197
207
  resource_config&.actions_config&.member_actions&.find { |a| a.name == name }
198
208
  end
@@ -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) {
@@ -1,7 +1,7 @@
1
1
  <% content_for :title, "#{resource_config.human_name_plural} - Admin Suite" %>
2
2
 
3
3
  <%
4
- has_new_route = begin
4
+ has_new_route = !resource_config.read_only? && begin
5
5
  url_for(action: :new)
6
6
  true
7
7
  rescue ActionController::UrlGenerationError
@@ -9,6 +9,8 @@
9
9
  end
10
10
 
11
11
  has_edit_route = ->(record) {
12
+ next false if resource_config.read_only?
13
+
12
14
  begin
13
15
  url_for(action: :edit, id: record.to_param)
14
16
  true
@@ -1,14 +1,14 @@
1
1
  <% content_for :title, "#{resource.respond_to?(:name) ? resource.name : "#{resource_config.human_name} ##{resource.id}"} - Admin Suite" %>
2
2
 
3
3
  <%
4
- has_edit_route = begin
4
+ has_edit_route = !resource_config.read_only? && begin
5
5
  url_for(action: :edit, id: resource.to_param)
6
6
  true
7
7
  rescue ActionController::UrlGenerationError
8
8
  false
9
9
  end
10
10
 
11
- has_destroy_route = begin
11
+ has_destroy_route = !resource_config.read_only? && begin
12
12
  url_for(action: :destroy, id: resource.to_param)
13
13
  true
14
14
  rescue ActionController::UrlGenerationError
@@ -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"
@@ -57,7 +57,8 @@ module Admin
57
57
  # They are handled elsewhere (`apply_sort`) and must not be turned into SQL.
58
58
  return scope if %i[sort sort_direction direction page search].include?(filter.name.to_sym)
59
59
 
60
- value = params[filter.name]
60
+ value = params[filter.name].presence
61
+ value = filter.default if value.blank? && filter.respond_to?(:default)
61
62
  return scope if value.blank?
62
63
 
63
64
  if filter.respond_to?(:apply) && filter.apply.present?
@@ -165,6 +165,17 @@ module Admin
165
165
  @export_formats = formats
166
166
  end
167
167
 
168
+ # Declares whether the generic resource routes may mutate records.
169
+ # Read-only resources retain index/show access while the controller
170
+ # rejects direct requests to every built-in mutation endpoint.
171
+ def read_only(value = true)
172
+ @read_only = value
173
+ end
174
+
175
+ def read_only?
176
+ @read_only == true
177
+ end
178
+
168
179
  # Returns the resource name derived from class name
169
180
  #
170
181
  # @return [String]
@@ -328,12 +339,13 @@ module Admin
328
339
  placeholder: options[:placeholder],
329
340
  options: select_options,
330
341
  field: options[:field] || name,
331
- apply: options[:apply]
342
+ apply: options[:apply],
343
+ default: options[:default]
332
344
  )
333
345
  end
334
346
  end
335
347
 
336
- FilterDefinition = Struct.new(:name, :type, :label, :placeholder, :options, :field, :apply, keyword_init: true)
348
+ FilterDefinition = Struct.new(:name, :type, :label, :placeholder, :options, :field, :apply, :default, keyword_init: true)
337
349
 
338
350
  class StatsBuilder
339
351
  attr_reader :stats
@@ -380,7 +392,8 @@ module Admin
380
392
  preview: options[:preview] != false,
381
393
  variants: options[:variants],
382
394
  label_color: options[:label_color],
383
- label_size: options[:label_size]
395
+ label_size: options[:label_size],
396
+ parent_field: options[:parent_field]
384
397
  )
385
398
  end
386
399
 
@@ -406,7 +419,7 @@ module Admin
406
419
  :name, :type, :required, :label, :help, :placeholder,
407
420
  :collection, :create_url, :accept, :rows, :readonly,
408
421
  :if_condition, :unless_condition, :multiple, :creatable,
409
- :preview, :variants, :label_color, :label_size,
422
+ :preview, :variants, :label_color, :label_size, :parent_field,
410
423
  keyword_init: true
411
424
  )
412
425
 
@@ -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.9"
6
6
  end
7
7
 
8
8
  # Backward-compatible constant.