hotwire_nested_form 1.1.0 → 1.3.0
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 +29 -1
- data/Gemfile +1 -0
- data/Gemfile.lock +4 -1
- data/README.md +174 -2
- data/lib/generators/hotwire_nested_form/templates/nested_form_controller.js +157 -11
- data/lib/hotwire_nested_form/form_builder_detector.rb +11 -0
- data/lib/hotwire_nested_form/version.rb +1 -1
- data/npm/README.md +59 -4
- data/npm/package.json +2 -2
- data/npm/src/nested_form_controller.js +154 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: de50acd45ca73b2f450facf4306904957892b14438c12e4b64aac7fa02eed9e9
|
|
4
|
+
data.tar.gz: c3e7e5096d346ba91d27f61ce6c20c28f3a076763769ab1e895b89df943a081d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ead6225736617ac2c6367effc43422804167929c0d5fca1962165627e89d589c8c001f0e476f96eee2c8050de0d765863fe7bc8aa3b48a9f027ef7f9eb3ca034
|
|
7
|
+
data.tar.gz: cde5e3da91c82bd35ea3813f45a576b9ad872fbd1b806dcd35df6830e49597f6c34288d3bc3ec780d9bc2da946be7f68a01fd5021fe6efe59a60d2876f1f59bd
|
data/CHANGELOG.md
CHANGED
|
@@ -7,11 +7,39 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [1.3.0] - 2026-02-06
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- Drag & drop sorting for nested items (requires SortableJS)
|
|
14
|
+
- `data-nested-form-sortable-value` - enable sorting
|
|
15
|
+
- `data-nested-form-position-field-value` - custom position field name
|
|
16
|
+
- `data-nested-form-sort-handle-value` - CSS selector for drag handle
|
|
17
|
+
- New events: `nested-form:before-sort` and `nested-form:after-sort`
|
|
18
|
+
- CSS classes for drag styling: `nested-form-dragging`, `nested-form-drag-ghost`
|
|
19
|
+
|
|
20
|
+
### Changed
|
|
21
|
+
- Controller now cleans up Sortable instance on disconnect
|
|
22
|
+
|
|
23
|
+
## [1.2.0] - 2026-02-05
|
|
24
|
+
|
|
25
|
+
### Added
|
|
26
|
+
- Dynamic min/max limits via data attributes
|
|
27
|
+
- `data-nested-form-min-value` - minimum items required
|
|
28
|
+
- `data-nested-form-max-value` - maximum items allowed
|
|
29
|
+
- `data-nested-form-limit-behavior-value` - behavior at limits: "disable", "hide", or "error"
|
|
30
|
+
- New events: `nested-form:limit-reached` and `nested-form:minimum-reached`
|
|
31
|
+
- Formtastic form builder auto-detection and compatibility
|
|
32
|
+
- `formtastic?` and `formtastic_available?` methods in FormBuilderDetector
|
|
33
|
+
|
|
34
|
+
### Changed
|
|
35
|
+
- Stimulus controller now updates button states automatically
|
|
36
|
+
- Buttons disable/hide based on current count vs limits
|
|
37
|
+
|
|
10
38
|
## [1.1.0] - 2026-02-05
|
|
11
39
|
|
|
12
40
|
### Added
|
|
13
41
|
- SimpleForm auto-detection and compatibility
|
|
14
|
-
- NPM package
|
|
42
|
+
- NPM package `hotwire-nested-form-stimulus` for JavaScript-only users
|
|
15
43
|
- `FormBuilderDetector` module for form builder type detection
|
|
16
44
|
|
|
17
45
|
### Changed
|
data/Gemfile
CHANGED
data/Gemfile.lock
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
PATH
|
|
2
2
|
remote: .
|
|
3
3
|
specs:
|
|
4
|
-
hotwire_nested_form (1.
|
|
4
|
+
hotwire_nested_form (1.3.0)
|
|
5
5
|
rails (>= 7.0)
|
|
6
6
|
|
|
7
7
|
GEM
|
|
@@ -105,6 +105,8 @@ GEM
|
|
|
105
105
|
drb (2.2.3)
|
|
106
106
|
erb (6.0.1)
|
|
107
107
|
erubi (1.13.1)
|
|
108
|
+
formtastic (5.0.0)
|
|
109
|
+
actionpack (>= 6.0.0)
|
|
108
110
|
globalid (1.3.0)
|
|
109
111
|
activesupport (>= 6.1)
|
|
110
112
|
i18n (1.14.8)
|
|
@@ -314,6 +316,7 @@ PLATFORMS
|
|
|
314
316
|
DEPENDENCIES
|
|
315
317
|
capybara (~> 3.39)
|
|
316
318
|
debug
|
|
319
|
+
formtastic (~> 5.0)
|
|
317
320
|
hotwire_nested_form!
|
|
318
321
|
puma (~> 6.0)
|
|
319
322
|
rails (~> 8.0.0)
|
data/README.md
CHANGED
|
@@ -104,19 +104,187 @@ Works automatically with SimpleForm! No configuration needed.
|
|
|
104
104
|
<% end %>
|
|
105
105
|
```
|
|
106
106
|
|
|
107
|
+
## Formtastic Support
|
|
108
|
+
|
|
109
|
+
Works automatically with Formtastic! No configuration needed.
|
|
110
|
+
|
|
111
|
+
```erb
|
|
112
|
+
<%= semantic_form_for @project do |f| %>
|
|
113
|
+
<%= f.input :name %>
|
|
114
|
+
|
|
115
|
+
<div data-controller="nested-form">
|
|
116
|
+
<%= f.semantic_fields_for :tasks do |task_form| %>
|
|
117
|
+
<%= render "task_fields", f: task_form %>
|
|
118
|
+
<% end %>
|
|
119
|
+
|
|
120
|
+
<%= link_to_add_association "Add Task", f, :tasks %>
|
|
121
|
+
</div>
|
|
122
|
+
|
|
123
|
+
<%= f.actions %>
|
|
124
|
+
<% end %>
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
## Min/Max Limits
|
|
128
|
+
|
|
129
|
+
Control the number of nested items with data attributes:
|
|
130
|
+
|
|
131
|
+
```erb
|
|
132
|
+
<div data-controller="nested-form"
|
|
133
|
+
data-nested-form-min-value="1"
|
|
134
|
+
data-nested-form-max-value="5"
|
|
135
|
+
data-nested-form-limit-behavior-value="disable">
|
|
136
|
+
|
|
137
|
+
<%= f.fields_for :tasks do |tf| %>
|
|
138
|
+
<%= render "task_fields", f: tf %>
|
|
139
|
+
<% end %>
|
|
140
|
+
|
|
141
|
+
<%= link_to_add_association "Add Task", f, :tasks %>
|
|
142
|
+
</div>
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
### Limit Options
|
|
146
|
+
|
|
147
|
+
| Attribute | Type | Default | Description |
|
|
148
|
+
|-----------|------|---------|-------------|
|
|
149
|
+
| `data-nested-form-min-value` | Integer | `0` | Minimum items required |
|
|
150
|
+
| `data-nested-form-max-value` | Integer | unlimited | Maximum items allowed |
|
|
151
|
+
| `data-nested-form-limit-behavior-value` | String | `"disable"` | `"disable"`, `"hide"`, or `"error"` |
|
|
152
|
+
|
|
153
|
+
### Limit Behaviors
|
|
154
|
+
|
|
155
|
+
| Behavior | At Max Limit | At Min Limit |
|
|
156
|
+
|----------|--------------|--------------|
|
|
157
|
+
| `disable` | Add button disabled | Remove buttons disabled |
|
|
158
|
+
| `hide` | Add button hidden | Remove buttons hidden |
|
|
159
|
+
| `error` | Event fires, button enabled | Event fires, button enabled |
|
|
160
|
+
|
|
161
|
+
### Dynamic Limits
|
|
162
|
+
|
|
163
|
+
Change limits at runtime via JavaScript:
|
|
164
|
+
|
|
165
|
+
```javascript
|
|
166
|
+
const form = document.querySelector('[data-controller="nested-form"]')
|
|
167
|
+
form.dataset.nestedFormMaxValue = 10 // Change max
|
|
168
|
+
form.dataset.nestedFormMinValue = 2 // Change min
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
### Limit Events
|
|
172
|
+
|
|
173
|
+
```javascript
|
|
174
|
+
document.addEventListener("nested-form:limit-reached", (event) => {
|
|
175
|
+
alert(`Maximum ${event.detail.limit} items allowed`)
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
document.addEventListener("nested-form:minimum-reached", (event) => {
|
|
179
|
+
alert(`Must keep at least ${event.detail.minimum} items`)
|
|
180
|
+
})
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
## Drag & Drop Sorting
|
|
184
|
+
|
|
185
|
+
Enable drag & drop reordering with position persistence:
|
|
186
|
+
|
|
187
|
+
### 1. Install SortableJS
|
|
188
|
+
|
|
189
|
+
```bash
|
|
190
|
+
# Rails with importmap
|
|
191
|
+
bin/importmap pin sortablejs
|
|
192
|
+
|
|
193
|
+
# OR npm/yarn
|
|
194
|
+
npm install sortablejs
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
### 2. Add Position to Your Model
|
|
198
|
+
|
|
199
|
+
```bash
|
|
200
|
+
rails generate migration AddPositionToTasks position:integer
|
|
201
|
+
rails db:migrate
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
```ruby
|
|
205
|
+
# app/models/task.rb
|
|
206
|
+
class Task < ApplicationRecord
|
|
207
|
+
belongs_to :project
|
|
208
|
+
default_scope { order(:position) }
|
|
209
|
+
end
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
### 3. Update Your Partial
|
|
213
|
+
|
|
214
|
+
```erb
|
|
215
|
+
<%# app/views/projects/_task_fields.html.erb %>
|
|
216
|
+
<div class="nested-fields">
|
|
217
|
+
<%= f.hidden_field :position %>
|
|
218
|
+
<span class="drag-handle">☰</span>
|
|
219
|
+
<%= f.text_field :name %>
|
|
220
|
+
<%= link_to_remove_association "Remove", f %>
|
|
221
|
+
</div>
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
### 4. Enable Sorting
|
|
225
|
+
|
|
226
|
+
```erb
|
|
227
|
+
<div data-controller="nested-form"
|
|
228
|
+
data-nested-form-sortable-value="true"
|
|
229
|
+
data-nested-form-sort-handle-value=".drag-handle">
|
|
230
|
+
<!-- nested fields -->
|
|
231
|
+
</div>
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
### 5. Permit Position in Controller
|
|
235
|
+
|
|
236
|
+
```ruby
|
|
237
|
+
params.require(:project).permit(:name,
|
|
238
|
+
tasks_attributes: [:id, :name, :position, :_destroy])
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
### Sorting Options
|
|
242
|
+
|
|
243
|
+
| Attribute | Default | Description |
|
|
244
|
+
|-----------|---------|-------------|
|
|
245
|
+
| `data-nested-form-sortable-value` | `false` | Enable drag & drop |
|
|
246
|
+
| `data-nested-form-position-field-value` | `"position"` | Position field name |
|
|
247
|
+
| `data-nested-form-sort-handle-value` | (none) | Drag handle selector |
|
|
248
|
+
|
|
249
|
+
### Sorting Events
|
|
250
|
+
|
|
251
|
+
| Event | Detail | Description |
|
|
252
|
+
|-------|--------|-------------|
|
|
253
|
+
| `nested-form:before-sort` | `{ item, oldIndex }` | Before drag (cancelable) |
|
|
254
|
+
| `nested-form:after-sort` | `{ item, oldIndex, newIndex }` | After drop |
|
|
255
|
+
|
|
256
|
+
### Example CSS
|
|
257
|
+
|
|
258
|
+
```css
|
|
259
|
+
.drag-handle {
|
|
260
|
+
cursor: grab;
|
|
261
|
+
user-select: none;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
.nested-form-dragging {
|
|
265
|
+
opacity: 0.8;
|
|
266
|
+
background: #e3f2fd;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
.nested-form-drag-ghost {
|
|
270
|
+
opacity: 0.4;
|
|
271
|
+
border: 2px dashed #2196F3;
|
|
272
|
+
}
|
|
273
|
+
```
|
|
274
|
+
|
|
107
275
|
## NPM Package (JavaScript-only)
|
|
108
276
|
|
|
109
277
|
For non-Rails projects using Stimulus, install via npm:
|
|
110
278
|
|
|
111
279
|
```bash
|
|
112
|
-
npm install
|
|
280
|
+
npm install hotwire-nested-form-stimulus
|
|
113
281
|
```
|
|
114
282
|
|
|
115
283
|
Register the controller:
|
|
116
284
|
|
|
117
285
|
```javascript
|
|
118
286
|
import { Application } from "@hotwired/stimulus"
|
|
119
|
-
import NestedFormController from "
|
|
287
|
+
import NestedFormController from "hotwire-nested-form-stimulus"
|
|
120
288
|
|
|
121
289
|
const application = Application.start()
|
|
122
290
|
application.register("nested-form", NestedFormController)
|
|
@@ -198,6 +366,10 @@ link_to_remove_association(name, form, options = {}, &block)
|
|
|
198
366
|
| `nested-form:after-add` | No | `{ wrapper }` | After fields added |
|
|
199
367
|
| `nested-form:before-remove` | Yes | `{ wrapper }` | Before removing fields |
|
|
200
368
|
| `nested-form:after-remove` | No | `{ wrapper }` | After fields removed |
|
|
369
|
+
| `nested-form:limit-reached` | No | `{ limit, current }` | When max limit reached |
|
|
370
|
+
| `nested-form:minimum-reached` | No | `{ minimum, current }` | When min limit reached |
|
|
371
|
+
| `nested-form:before-sort` | Yes | `{ item, oldIndex }` | Before drag starts |
|
|
372
|
+
| `nested-form:after-sort` | No | `{ item, oldIndex, newIndex }` | After drop completes |
|
|
201
373
|
|
|
202
374
|
**Usage Examples:**
|
|
203
375
|
|
|
@@ -1,31 +1,74 @@
|
|
|
1
1
|
import { Controller } from "@hotwired/stimulus"
|
|
2
2
|
|
|
3
|
-
// Connects to data-controller="nested-form"
|
|
4
3
|
export default class extends Controller {
|
|
5
4
|
static values = {
|
|
6
|
-
wrapperClass: { type: String, default: "nested-fields" }
|
|
5
|
+
wrapperClass: { type: String, default: "nested-fields" },
|
|
6
|
+
min: { type: Number, default: 0 },
|
|
7
|
+
max: { type: Number, default: 999999 },
|
|
8
|
+
limitBehavior: { type: String, default: "disable" },
|
|
9
|
+
sortable: { type: Boolean, default: false },
|
|
10
|
+
positionField: { type: String, default: "position" },
|
|
11
|
+
sortHandle: { type: String, default: "" }
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
connect() {
|
|
15
|
+
this.updateButtonStates()
|
|
16
|
+
if (this.sortableValue) this.initializeSortable()
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
disconnect() {
|
|
20
|
+
if (this.sortableInstance) this.sortableInstance.destroy()
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
get currentCount() {
|
|
24
|
+
return this.element.querySelectorAll(`.${this.wrapperClassValue}:not([style*="display: none"])`).length
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
get addButtons() {
|
|
28
|
+
return this.element.querySelectorAll('[data-action*="nested-form#add"]')
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
get removeButtons() {
|
|
32
|
+
return this.element.querySelectorAll('[data-action*="nested-form#remove"]')
|
|
7
33
|
}
|
|
8
34
|
|
|
9
35
|
add(event) {
|
|
10
36
|
event.preventDefault()
|
|
11
37
|
|
|
38
|
+
if (this.currentCount >= this.maxValue) {
|
|
39
|
+
this.dispatch("limit-reached", {
|
|
40
|
+
detail: { limit: this.maxValue, current: this.currentCount }
|
|
41
|
+
})
|
|
42
|
+
return
|
|
43
|
+
}
|
|
44
|
+
|
|
12
45
|
const template = event.currentTarget.dataset.template
|
|
13
46
|
const insertion = event.currentTarget.dataset.insertion || "before"
|
|
14
47
|
const targetSelector = event.currentTarget.dataset.target
|
|
15
48
|
const count = parseInt(event.currentTarget.dataset.count) || 1
|
|
16
49
|
|
|
17
50
|
for (let i = 0; i < count; i++) {
|
|
18
|
-
this
|
|
51
|
+
if (this.currentCount >= this.maxValue) break
|
|
52
|
+
this.insertFields(template, insertion, targetSelector, event.currentTarget)
|
|
19
53
|
}
|
|
54
|
+
|
|
55
|
+
this.updateButtonStates()
|
|
56
|
+
if (this.sortableValue && !this.sortableInstance) this.initializeSortable()
|
|
20
57
|
}
|
|
21
58
|
|
|
22
59
|
remove(event) {
|
|
23
60
|
event.preventDefault()
|
|
24
61
|
|
|
62
|
+
if (this.currentCount <= this.minValue) {
|
|
63
|
+
this.dispatch("minimum-reached", {
|
|
64
|
+
detail: { minimum: this.minValue, current: this.currentCount }
|
|
65
|
+
})
|
|
66
|
+
return
|
|
67
|
+
}
|
|
68
|
+
|
|
25
69
|
const wrapper = event.currentTarget.closest(`.${this.wrapperClassValue}`)
|
|
26
70
|
if (!wrapper) return
|
|
27
71
|
|
|
28
|
-
// Dispatch cancelable before-remove event
|
|
29
72
|
const beforeEvent = this.dispatch("before-remove", {
|
|
30
73
|
cancelable: true,
|
|
31
74
|
detail: { wrapper }
|
|
@@ -36,27 +79,23 @@ export default class extends Controller {
|
|
|
36
79
|
const destroyInput = wrapper.querySelector("input[name*='_destroy']")
|
|
37
80
|
|
|
38
81
|
if (destroyInput) {
|
|
39
|
-
// Persisted record - hide and mark for destruction
|
|
40
82
|
destroyInput.value = "true"
|
|
41
83
|
wrapper.style.display = "none"
|
|
42
84
|
} else {
|
|
43
|
-
// New record - remove from DOM
|
|
44
85
|
wrapper.remove()
|
|
45
86
|
}
|
|
46
87
|
|
|
47
88
|
this.dispatch("after-remove", { detail: { wrapper } })
|
|
89
|
+
this.updateButtonStates()
|
|
48
90
|
}
|
|
49
91
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
#insertFields(template, insertion, targetSelector, trigger) {
|
|
92
|
+
insertFields(template, insertion, targetSelector, trigger) {
|
|
53
93
|
const newId = new Date().getTime()
|
|
54
94
|
const content = template.replace(/NEW_RECORD/g, newId)
|
|
55
95
|
|
|
56
96
|
const fragment = document.createRange().createContextualFragment(content)
|
|
57
97
|
const wrapper = fragment.firstElementChild
|
|
58
98
|
|
|
59
|
-
// Dispatch cancelable before-add event
|
|
60
99
|
const beforeEvent = this.dispatch("before-add", {
|
|
61
100
|
cancelable: true,
|
|
62
101
|
detail: { wrapper }
|
|
@@ -78,10 +117,117 @@ export default class extends Controller {
|
|
|
78
117
|
case "prepend":
|
|
79
118
|
container.prepend(fragment)
|
|
80
119
|
break
|
|
81
|
-
default:
|
|
120
|
+
default:
|
|
82
121
|
trigger.before(fragment)
|
|
83
122
|
}
|
|
84
123
|
|
|
85
124
|
this.dispatch("after-add", { detail: { wrapper } })
|
|
86
125
|
}
|
|
126
|
+
|
|
127
|
+
updateButtonStates() {
|
|
128
|
+
const atMax = this.currentCount >= this.maxValue
|
|
129
|
+
const atMin = this.currentCount <= this.minValue
|
|
130
|
+
|
|
131
|
+
this.addButtons.forEach(button => {
|
|
132
|
+
this.applyLimitState(button, atMax)
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
this.removeButtons.forEach(button => {
|
|
136
|
+
const wrapper = button.closest(`.${this.wrapperClassValue}`)
|
|
137
|
+
if (wrapper && wrapper.style.display !== "none") {
|
|
138
|
+
this.applyLimitState(button, atMin)
|
|
139
|
+
}
|
|
140
|
+
})
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
applyLimitState(button, isAtLimit) {
|
|
144
|
+
switch (this.limitBehaviorValue) {
|
|
145
|
+
case "hide":
|
|
146
|
+
button.style.display = isAtLimit ? "none" : ""
|
|
147
|
+
button.disabled = false
|
|
148
|
+
break
|
|
149
|
+
case "error":
|
|
150
|
+
button.disabled = false
|
|
151
|
+
button.style.display = ""
|
|
152
|
+
break
|
|
153
|
+
default: // "disable"
|
|
154
|
+
button.disabled = isAtLimit
|
|
155
|
+
button.style.display = ""
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Drag & Drop Sorting
|
|
160
|
+
|
|
161
|
+
initializeSortable() {
|
|
162
|
+
if (typeof Sortable === 'undefined') {
|
|
163
|
+
console.warn('hotwire_nested_form: SortableJS not found. Install it for drag & drop sorting: https://sortablejs.github.io/Sortable/')
|
|
164
|
+
return
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const container = this.findSortableContainer()
|
|
168
|
+
if (!container) return
|
|
169
|
+
|
|
170
|
+
this.sortableInstance = Sortable.create(container, {
|
|
171
|
+
animation: 150,
|
|
172
|
+
handle: this.sortHandleValue || null,
|
|
173
|
+
draggable: `.${this.wrapperClassValue}`,
|
|
174
|
+
ghostClass: 'nested-form-drag-ghost',
|
|
175
|
+
chosenClass: 'nested-form-dragging',
|
|
176
|
+
onStart: (evt) => this.onSortStart(evt),
|
|
177
|
+
onEnd: (evt) => this.onSortEnd(evt)
|
|
178
|
+
})
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
findSortableContainer() {
|
|
182
|
+
// Look for common container patterns
|
|
183
|
+
const selectors = ['#tasks', '#items', '[data-nested-form-target="container"]']
|
|
184
|
+
for (const selector of selectors) {
|
|
185
|
+
const container = this.element.querySelector(selector)
|
|
186
|
+
if (container) return container
|
|
187
|
+
}
|
|
188
|
+
// Fallback: find first element containing nested-fields
|
|
189
|
+
const firstField = this.element.querySelector(`.${this.wrapperClassValue}`)
|
|
190
|
+
return firstField ? firstField.parentElement : this.element
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
onSortStart(evt) {
|
|
194
|
+
const beforeEvent = this.dispatch("before-sort", {
|
|
195
|
+
cancelable: true,
|
|
196
|
+
detail: { item: evt.item, oldIndex: evt.oldIndex }
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
if (beforeEvent.defaultPrevented) {
|
|
200
|
+
this.sortableInstance.option("disabled", true)
|
|
201
|
+
setTimeout(() => this.sortableInstance.option("disabled", false), 0)
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
onSortEnd(evt) {
|
|
206
|
+
this.updatePositions()
|
|
207
|
+
|
|
208
|
+
this.dispatch("after-sort", {
|
|
209
|
+
detail: {
|
|
210
|
+
item: evt.item,
|
|
211
|
+
oldIndex: evt.oldIndex,
|
|
212
|
+
newIndex: evt.newIndex
|
|
213
|
+
}
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
this.updateButtonStates()
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
updatePositions() {
|
|
220
|
+
const items = this.element.querySelectorAll(
|
|
221
|
+
`.${this.wrapperClassValue}:not([style*="display: none"])`
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
items.forEach((item, index) => {
|
|
225
|
+
const positionInput = item.querySelector(
|
|
226
|
+
`input[name*="[${this.positionFieldValue}]"]`
|
|
227
|
+
)
|
|
228
|
+
if (positionInput) {
|
|
229
|
+
positionInput.value = index + 1
|
|
230
|
+
}
|
|
231
|
+
})
|
|
232
|
+
}
|
|
87
233
|
}
|
|
@@ -11,8 +11,19 @@ module HotwireNestedForm
|
|
|
11
11
|
builder_class.include?('SimpleForm')
|
|
12
12
|
end
|
|
13
13
|
|
|
14
|
+
def formtastic?(form_builder)
|
|
15
|
+
return false unless form_builder
|
|
16
|
+
|
|
17
|
+
builder_class = form_builder.class.name.to_s
|
|
18
|
+
builder_class.include?('Formtastic')
|
|
19
|
+
end
|
|
20
|
+
|
|
14
21
|
def simple_form_available?
|
|
15
22
|
defined?(::SimpleForm) ? true : false
|
|
16
23
|
end
|
|
24
|
+
|
|
25
|
+
def formtastic_available?
|
|
26
|
+
defined?(::Formtastic) ? true : false
|
|
27
|
+
end
|
|
17
28
|
end
|
|
18
29
|
end
|
data/npm/README.md
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
|
-
#
|
|
1
|
+
# hotwire-nested-form-stimulus
|
|
2
2
|
|
|
3
3
|
A Stimulus controller for dynamic nested forms. Add and remove nested form fields with ease.
|
|
4
4
|
|
|
5
5
|
## Installation
|
|
6
6
|
|
|
7
7
|
```bash
|
|
8
|
-
npm install
|
|
8
|
+
npm install hotwire-nested-form-stimulus
|
|
9
9
|
# or
|
|
10
|
-
yarn add
|
|
10
|
+
yarn add hotwire-nested-form-stimulus
|
|
11
11
|
```
|
|
12
12
|
|
|
13
13
|
## Usage
|
|
@@ -16,7 +16,7 @@ yarn add @hotwire-nested-form/stimulus
|
|
|
16
16
|
|
|
17
17
|
```javascript
|
|
18
18
|
import { Application } from "@hotwired/stimulus"
|
|
19
|
-
import NestedFormController from "
|
|
19
|
+
import NestedFormController from "hotwire-nested-form-stimulus"
|
|
20
20
|
|
|
21
21
|
const application = Application.start()
|
|
22
22
|
application.register("nested-form", NestedFormController)
|
|
@@ -47,6 +47,57 @@ application.register("nested-form", NestedFormController)
|
|
|
47
47
|
| `data-count` | Number of fields to add per click | `1` |
|
|
48
48
|
| `data-target` | CSS selector for insertion container | Parent element |
|
|
49
49
|
|
|
50
|
+
### Min/Max Limits
|
|
51
|
+
|
|
52
|
+
```html
|
|
53
|
+
<div data-controller="nested-form"
|
|
54
|
+
data-nested-form-min-value="1"
|
|
55
|
+
data-nested-form-max-value="5"
|
|
56
|
+
data-nested-form-limit-behavior-value="disable">
|
|
57
|
+
<!-- fields here -->
|
|
58
|
+
</div>
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
| Attribute | Description | Default |
|
|
62
|
+
|-----------|-------------|---------|
|
|
63
|
+
| `data-nested-form-min-value` | Minimum items required | `0` |
|
|
64
|
+
| `data-nested-form-max-value` | Maximum items allowed | unlimited |
|
|
65
|
+
| `data-nested-form-limit-behavior-value` | `"disable"`, `"hide"`, or `"error"` | `"disable"` |
|
|
66
|
+
|
|
67
|
+
### Drag & Drop Sorting
|
|
68
|
+
|
|
69
|
+
Requires [SortableJS](https://sortablejs.github.io/Sortable/):
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
npm install sortablejs
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
```javascript
|
|
76
|
+
import Sortable from 'sortablejs'
|
|
77
|
+
window.Sortable = Sortable
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
```html
|
|
81
|
+
<div data-controller="nested-form"
|
|
82
|
+
data-nested-form-sortable-value="true"
|
|
83
|
+
data-nested-form-sort-handle-value=".drag-handle">
|
|
84
|
+
|
|
85
|
+
<div id="items">
|
|
86
|
+
<div class="nested-fields">
|
|
87
|
+
<input type="hidden" name="items[][position]" value="1">
|
|
88
|
+
<span class="drag-handle">☰</span>
|
|
89
|
+
<!-- other fields -->
|
|
90
|
+
</div>
|
|
91
|
+
</div>
|
|
92
|
+
</div>
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
| Attribute | Default | Description |
|
|
96
|
+
|-----------|---------|-------------|
|
|
97
|
+
| `data-nested-form-sortable-value` | `false` | Enable sorting |
|
|
98
|
+
| `data-nested-form-position-field-value` | `"position"` | Position field name |
|
|
99
|
+
| `data-nested-form-sort-handle-value` | (none) | Drag handle selector |
|
|
100
|
+
|
|
50
101
|
### Events
|
|
51
102
|
|
|
52
103
|
| Event | Cancelable | Detail |
|
|
@@ -55,6 +106,10 @@ application.register("nested-form", NestedFormController)
|
|
|
55
106
|
| `nested-form:after-add` | No | `{ wrapper }` |
|
|
56
107
|
| `nested-form:before-remove` | Yes | `{ wrapper }` |
|
|
57
108
|
| `nested-form:after-remove` | No | `{ wrapper }` |
|
|
109
|
+
| `nested-form:limit-reached` | No | `{ limit, current }` |
|
|
110
|
+
| `nested-form:minimum-reached` | No | `{ minimum, current }` |
|
|
111
|
+
| `nested-form:before-sort` | Yes | `{ item, oldIndex }` |
|
|
112
|
+
| `nested-form:after-sort` | No | `{ item, oldIndex, newIndex }` |
|
|
58
113
|
|
|
59
114
|
### Example: Listen for Events
|
|
60
115
|
|
data/npm/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
|
-
"name": "
|
|
3
|
-
"version": "1.
|
|
2
|
+
"name": "hotwire-nested-form-stimulus",
|
|
3
|
+
"version": "1.3.0",
|
|
4
4
|
"description": "Stimulus controller for dynamic nested forms - works with Rails, React, Vue, or any Stimulus app",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"module": "src/index.js",
|
|
@@ -2,25 +2,70 @@ import { Controller } from "@hotwired/stimulus"
|
|
|
2
2
|
|
|
3
3
|
export default class extends Controller {
|
|
4
4
|
static values = {
|
|
5
|
-
wrapperClass: { type: String, default: "nested-fields" }
|
|
5
|
+
wrapperClass: { type: String, default: "nested-fields" },
|
|
6
|
+
min: { type: Number, default: 0 },
|
|
7
|
+
max: { type: Number, default: 999999 },
|
|
8
|
+
limitBehavior: { type: String, default: "disable" },
|
|
9
|
+
sortable: { type: Boolean, default: false },
|
|
10
|
+
positionField: { type: String, default: "position" },
|
|
11
|
+
sortHandle: { type: String, default: "" }
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
connect() {
|
|
15
|
+
this.updateButtonStates()
|
|
16
|
+
if (this.sortableValue) this.initializeSortable()
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
disconnect() {
|
|
20
|
+
if (this.sortableInstance) this.sortableInstance.destroy()
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
get currentCount() {
|
|
24
|
+
return this.element.querySelectorAll(`.${this.wrapperClassValue}:not([style*="display: none"])`).length
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
get addButtons() {
|
|
28
|
+
return this.element.querySelectorAll('[data-action*="nested-form#add"]')
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
get removeButtons() {
|
|
32
|
+
return this.element.querySelectorAll('[data-action*="nested-form#remove"]')
|
|
6
33
|
}
|
|
7
34
|
|
|
8
35
|
add(event) {
|
|
9
36
|
event.preventDefault()
|
|
10
37
|
|
|
38
|
+
if (this.currentCount >= this.maxValue) {
|
|
39
|
+
this.dispatch("limit-reached", {
|
|
40
|
+
detail: { limit: this.maxValue, current: this.currentCount }
|
|
41
|
+
})
|
|
42
|
+
return
|
|
43
|
+
}
|
|
44
|
+
|
|
11
45
|
const template = event.currentTarget.dataset.template
|
|
12
46
|
const insertion = event.currentTarget.dataset.insertion || "before"
|
|
13
47
|
const targetSelector = event.currentTarget.dataset.target
|
|
14
48
|
const count = parseInt(event.currentTarget.dataset.count) || 1
|
|
15
49
|
|
|
16
50
|
for (let i = 0; i < count; i++) {
|
|
51
|
+
if (this.currentCount >= this.maxValue) break
|
|
17
52
|
this.insertFields(template, insertion, targetSelector, event.currentTarget)
|
|
18
53
|
}
|
|
54
|
+
|
|
55
|
+
this.updateButtonStates()
|
|
56
|
+
if (this.sortableValue && !this.sortableInstance) this.initializeSortable()
|
|
19
57
|
}
|
|
20
58
|
|
|
21
59
|
remove(event) {
|
|
22
60
|
event.preventDefault()
|
|
23
61
|
|
|
62
|
+
if (this.currentCount <= this.minValue) {
|
|
63
|
+
this.dispatch("minimum-reached", {
|
|
64
|
+
detail: { minimum: this.minValue, current: this.currentCount }
|
|
65
|
+
})
|
|
66
|
+
return
|
|
67
|
+
}
|
|
68
|
+
|
|
24
69
|
const wrapper = event.currentTarget.closest(`.${this.wrapperClassValue}`)
|
|
25
70
|
if (!wrapper) return
|
|
26
71
|
|
|
@@ -41,6 +86,7 @@ export default class extends Controller {
|
|
|
41
86
|
}
|
|
42
87
|
|
|
43
88
|
this.dispatch("after-remove", { detail: { wrapper } })
|
|
89
|
+
this.updateButtonStates()
|
|
44
90
|
}
|
|
45
91
|
|
|
46
92
|
insertFields(template, insertion, targetSelector, trigger) {
|
|
@@ -77,4 +123,111 @@ export default class extends Controller {
|
|
|
77
123
|
|
|
78
124
|
this.dispatch("after-add", { detail: { wrapper } })
|
|
79
125
|
}
|
|
126
|
+
|
|
127
|
+
updateButtonStates() {
|
|
128
|
+
const atMax = this.currentCount >= this.maxValue
|
|
129
|
+
const atMin = this.currentCount <= this.minValue
|
|
130
|
+
|
|
131
|
+
this.addButtons.forEach(button => {
|
|
132
|
+
this.applyLimitState(button, atMax)
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
this.removeButtons.forEach(button => {
|
|
136
|
+
const wrapper = button.closest(`.${this.wrapperClassValue}`)
|
|
137
|
+
if (wrapper && wrapper.style.display !== "none") {
|
|
138
|
+
this.applyLimitState(button, atMin)
|
|
139
|
+
}
|
|
140
|
+
})
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
applyLimitState(button, isAtLimit) {
|
|
144
|
+
switch (this.limitBehaviorValue) {
|
|
145
|
+
case "hide":
|
|
146
|
+
button.style.display = isAtLimit ? "none" : ""
|
|
147
|
+
button.disabled = false
|
|
148
|
+
break
|
|
149
|
+
case "error":
|
|
150
|
+
button.disabled = false
|
|
151
|
+
button.style.display = ""
|
|
152
|
+
break
|
|
153
|
+
default: // "disable"
|
|
154
|
+
button.disabled = isAtLimit
|
|
155
|
+
button.style.display = ""
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Drag & Drop Sorting
|
|
160
|
+
|
|
161
|
+
initializeSortable() {
|
|
162
|
+
if (typeof Sortable === 'undefined') {
|
|
163
|
+
console.warn('hotwire_nested_form: SortableJS not found. Install it for drag & drop sorting: https://sortablejs.github.io/Sortable/')
|
|
164
|
+
return
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const container = this.findSortableContainer()
|
|
168
|
+
if (!container) return
|
|
169
|
+
|
|
170
|
+
this.sortableInstance = Sortable.create(container, {
|
|
171
|
+
animation: 150,
|
|
172
|
+
handle: this.sortHandleValue || null,
|
|
173
|
+
draggable: `.${this.wrapperClassValue}`,
|
|
174
|
+
ghostClass: 'nested-form-drag-ghost',
|
|
175
|
+
chosenClass: 'nested-form-dragging',
|
|
176
|
+
onStart: (evt) => this.onSortStart(evt),
|
|
177
|
+
onEnd: (evt) => this.onSortEnd(evt)
|
|
178
|
+
})
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
findSortableContainer() {
|
|
182
|
+
// Look for common container patterns
|
|
183
|
+
const selectors = ['#tasks', '#items', '[data-nested-form-target="container"]']
|
|
184
|
+
for (const selector of selectors) {
|
|
185
|
+
const container = this.element.querySelector(selector)
|
|
186
|
+
if (container) return container
|
|
187
|
+
}
|
|
188
|
+
// Fallback: find first element containing nested-fields
|
|
189
|
+
const firstField = this.element.querySelector(`.${this.wrapperClassValue}`)
|
|
190
|
+
return firstField ? firstField.parentElement : this.element
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
onSortStart(evt) {
|
|
194
|
+
const beforeEvent = this.dispatch("before-sort", {
|
|
195
|
+
cancelable: true,
|
|
196
|
+
detail: { item: evt.item, oldIndex: evt.oldIndex }
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
if (beforeEvent.defaultPrevented) {
|
|
200
|
+
this.sortableInstance.option("disabled", true)
|
|
201
|
+
setTimeout(() => this.sortableInstance.option("disabled", false), 0)
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
onSortEnd(evt) {
|
|
206
|
+
this.updatePositions()
|
|
207
|
+
|
|
208
|
+
this.dispatch("after-sort", {
|
|
209
|
+
detail: {
|
|
210
|
+
item: evt.item,
|
|
211
|
+
oldIndex: evt.oldIndex,
|
|
212
|
+
newIndex: evt.newIndex
|
|
213
|
+
}
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
this.updateButtonStates()
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
updatePositions() {
|
|
220
|
+
const items = this.element.querySelectorAll(
|
|
221
|
+
`.${this.wrapperClassValue}:not([style*="display: none"])`
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
items.forEach((item, index) => {
|
|
225
|
+
const positionInput = item.querySelector(
|
|
226
|
+
`input[name*="[${this.positionFieldValue}]"]`
|
|
227
|
+
)
|
|
228
|
+
if (positionInput) {
|
|
229
|
+
positionInput.value = index + 1
|
|
230
|
+
}
|
|
231
|
+
})
|
|
232
|
+
}
|
|
80
233
|
}
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: hotwire_nested_form
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.
|
|
4
|
+
version: 1.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- BhumitBhadani
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-02-
|
|
11
|
+
date: 2026-02-06 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: rails
|