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 +4 -4
- data/CHANGELOG.md +23 -1
- data/CONTRIBUTING.md +2 -2
- data/README.md +10 -24
- data/app/controllers/admin_suite/resources_controller.rb +14 -4
- data/app/helpers/admin_suite/base_helper.rb +56 -0
- data/app/javascript/admin_suite_application.js +44 -0
- data/app/javascript/controllers/admin_suite/dependent_searchable_select_controller.js +228 -0
- data/app/javascript/controllers/admin_suite/searchable_select_controller.js +1 -0
- data/app/views/admin_suite/resources/index.html.erb +3 -1
- data/app/views/admin_suite/resources/show.html.erb +2 -2
- data/app/views/layouts/admin_suite/application.html.erb +1 -0
- data/config/importmap.rb +1 -0
- data/lib/admin/base/filter_builder.rb +2 -1
- data/lib/admin/base/resource.rb +17 -4
- data/lib/admin_suite/ui/field_renderer_registry.rb +4 -0
- data/lib/admin_suite/version.rb +1 -1
- data/test/controllers/resources_controller_test.rb +80 -0
- data/test/integration/read_only_resource_test.rb +143 -0
- data/test/lib/resource_observability_extensions_test.rb +53 -0
- metadata +7 -14
- data/docs/README.md +0 -26
- data/docs/actions.md +0 -98
- data/docs/configuration.md +0 -284
- data/docs/development.md +0 -64
- data/docs/docs_viewer.md +0 -79
- data/docs/fields.md +0 -188
- data/docs/installation.md +0 -80
- data/docs/portals.md +0 -140
- data/docs/releasing.md +0 -67
- data/docs/resources.md +0 -237
- data/docs/theming.md +0 -63
- data/docs/troubleshooting.md +0 -50
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f50f5a24977b7bc8284ee051fb3d9d1746b49a1ab8d9ade00d917bf0a8ff412b
|
|
4
|
+
data.tar.gz: 6e32362f8fe763980b83fa34910b21020d7bdca7918afb5d63084aae7e80d72e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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
|
|
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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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
|
-
-
|
|
142
|
-
-
|
|
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
|
-
|
|
15
|
-
@
|
|
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.
|
|
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, "&")
|
|
223
|
+
.replace(/"/g, """)
|
|
224
|
+
.replace(/'/g, "'")
|
|
225
|
+
.replace(/</g, "<")
|
|
226
|
+
.replace(/>/g, ">")
|
|
227
|
+
}
|
|
228
|
+
}
|
|
@@ -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
|
data/config/importmap.rb
CHANGED
|
@@ -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?
|
data/lib/admin/base/resource.rb
CHANGED
|
@@ -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
|