hotwire_nested_form 1.2.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 +13 -0
- data/Gemfile.lock +1 -1
- data/README.md +94 -0
- data/lib/generators/hotwire_nested_form/templates/nested_form_controller.js +85 -1
- data/lib/hotwire_nested_form/version.rb +1 -1
- data/npm/README.md +36 -0
- data/npm/package.json +1 -1
- data/npm/src/nested_form_controller.js +85 -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,6 +7,19 @@ 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
|
+
|
|
10
23
|
## [1.2.0] - 2026-02-05
|
|
11
24
|
|
|
12
25
|
### Added
|
data/Gemfile.lock
CHANGED
data/README.md
CHANGED
|
@@ -180,6 +180,98 @@ document.addEventListener("nested-form:minimum-reached", (event) => {
|
|
|
180
180
|
})
|
|
181
181
|
```
|
|
182
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
|
+
|
|
183
275
|
## NPM Package (JavaScript-only)
|
|
184
276
|
|
|
185
277
|
For non-Rails projects using Stimulus, install via npm:
|
|
@@ -276,6 +368,8 @@ link_to_remove_association(name, form, options = {}, &block)
|
|
|
276
368
|
| `nested-form:after-remove` | No | `{ wrapper }` | After fields removed |
|
|
277
369
|
| `nested-form:limit-reached` | No | `{ limit, current }` | When max limit reached |
|
|
278
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 |
|
|
279
373
|
|
|
280
374
|
**Usage Examples:**
|
|
281
375
|
|
|
@@ -5,11 +5,19 @@ export default class extends Controller {
|
|
|
5
5
|
wrapperClass: { type: String, default: "nested-fields" },
|
|
6
6
|
min: { type: Number, default: 0 },
|
|
7
7
|
max: { type: Number, default: 999999 },
|
|
8
|
-
limitBehavior: { type: String, default: "disable" }
|
|
8
|
+
limitBehavior: { type: String, default: "disable" },
|
|
9
|
+
sortable: { type: Boolean, default: false },
|
|
10
|
+
positionField: { type: String, default: "position" },
|
|
11
|
+
sortHandle: { type: String, default: "" }
|
|
9
12
|
}
|
|
10
13
|
|
|
11
14
|
connect() {
|
|
12
15
|
this.updateButtonStates()
|
|
16
|
+
if (this.sortableValue) this.initializeSortable()
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
disconnect() {
|
|
20
|
+
if (this.sortableInstance) this.sortableInstance.destroy()
|
|
13
21
|
}
|
|
14
22
|
|
|
15
23
|
get currentCount() {
|
|
@@ -45,6 +53,7 @@ export default class extends Controller {
|
|
|
45
53
|
}
|
|
46
54
|
|
|
47
55
|
this.updateButtonStates()
|
|
56
|
+
if (this.sortableValue && !this.sortableInstance) this.initializeSortable()
|
|
48
57
|
}
|
|
49
58
|
|
|
50
59
|
remove(event) {
|
|
@@ -146,4 +155,79 @@ export default class extends Controller {
|
|
|
146
155
|
button.style.display = ""
|
|
147
156
|
}
|
|
148
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
|
+
}
|
|
149
233
|
}
|
data/npm/README.md
CHANGED
|
@@ -64,6 +64,40 @@ application.register("nested-form", NestedFormController)
|
|
|
64
64
|
| `data-nested-form-max-value` | Maximum items allowed | unlimited |
|
|
65
65
|
| `data-nested-form-limit-behavior-value` | `"disable"`, `"hide"`, or `"error"` | `"disable"` |
|
|
66
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
|
+
|
|
67
101
|
### Events
|
|
68
102
|
|
|
69
103
|
| Event | Cancelable | Detail |
|
|
@@ -74,6 +108,8 @@ application.register("nested-form", NestedFormController)
|
|
|
74
108
|
| `nested-form:after-remove` | No | `{ wrapper }` |
|
|
75
109
|
| `nested-form:limit-reached` | No | `{ limit, current }` |
|
|
76
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 }` |
|
|
77
113
|
|
|
78
114
|
### Example: Listen for Events
|
|
79
115
|
|
data/npm/package.json
CHANGED
|
@@ -5,11 +5,19 @@ export default class extends Controller {
|
|
|
5
5
|
wrapperClass: { type: String, default: "nested-fields" },
|
|
6
6
|
min: { type: Number, default: 0 },
|
|
7
7
|
max: { type: Number, default: 999999 },
|
|
8
|
-
limitBehavior: { type: String, default: "disable" }
|
|
8
|
+
limitBehavior: { type: String, default: "disable" },
|
|
9
|
+
sortable: { type: Boolean, default: false },
|
|
10
|
+
positionField: { type: String, default: "position" },
|
|
11
|
+
sortHandle: { type: String, default: "" }
|
|
9
12
|
}
|
|
10
13
|
|
|
11
14
|
connect() {
|
|
12
15
|
this.updateButtonStates()
|
|
16
|
+
if (this.sortableValue) this.initializeSortable()
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
disconnect() {
|
|
20
|
+
if (this.sortableInstance) this.sortableInstance.destroy()
|
|
13
21
|
}
|
|
14
22
|
|
|
15
23
|
get currentCount() {
|
|
@@ -45,6 +53,7 @@ export default class extends Controller {
|
|
|
45
53
|
}
|
|
46
54
|
|
|
47
55
|
this.updateButtonStates()
|
|
56
|
+
if (this.sortableValue && !this.sortableInstance) this.initializeSortable()
|
|
48
57
|
}
|
|
49
58
|
|
|
50
59
|
remove(event) {
|
|
@@ -146,4 +155,79 @@ export default class extends Controller {
|
|
|
146
155
|
button.style.display = ""
|
|
147
156
|
}
|
|
148
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
|
+
}
|
|
149
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
|