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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 95575593f362dd0db15938a6b130eb2fa7353e12b4c939c84ee4aa1eb00035ec
4
- data.tar.gz: 8ace6c3f971faaadc42d2692a6540bd4a907387302963573a904ad7990b1a3b6
3
+ metadata.gz: de50acd45ca73b2f450facf4306904957892b14438c12e4b64aac7fa02eed9e9
4
+ data.tar.gz: c3e7e5096d346ba91d27f61ce6c20c28f3a076763769ab1e895b89df943a081d
5
5
  SHA512:
6
- metadata.gz: 409ccb4b58b6be6caba5a0b8b5634f999337677de6c146ea3bb100bb3bab9496dca69f4ae5c72ed2587419d321552bb4c7bfb09788ac7664069c0fd2e5ae7e0d
7
- data.tar.gz: 4b5902764ddc0e1e7b3223cd6f449a325089198a4a0fadee4077638eb7b403bc0179f0105233950c63b73f828339c6b796f577e3d807e67ec66137e286784791
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
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- hotwire_nested_form (1.2.0)
4
+ hotwire_nested_form (1.3.0)
5
5
  rails (>= 7.0)
6
6
 
7
7
  GEM
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
  }
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module HotwireNestedForm
4
- VERSION = '1.2.0'
4
+ VERSION = '1.3.0'
5
5
  end
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hotwire-nested-form-stimulus",
3
- "version": "1.2.0",
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",
@@ -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.2.0
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-05 00:00:00.000000000 Z
11
+ date: 2026-02-06 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails