hotwire_nested_form 1.2.0 → 1.4.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 +35 -0
- data/Gemfile.lock +1 -1
- data/README.md +211 -0
- data/app/assets/stylesheets/hotwire_nested_form/animations.css +47 -0
- data/lib/generators/hotwire_nested_form/install_generator.rb +10 -0
- data/lib/generators/hotwire_nested_form/templates/animations.css +47 -0
- data/lib/generators/hotwire_nested_form/templates/nested_form_controller.js +142 -3
- data/lib/hotwire_nested_form/helpers/add_association.rb +17 -9
- data/lib/hotwire_nested_form/version.rb +1 -1
- data/npm/README.md +107 -3
- data/npm/css/animations.css +46 -0
- data/npm/package.json +3 -2
- data/npm/src/nested_form_controller.js +142 -3
- metadata +5 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 173878e857afa1600895b0db444cb9c60785b587e79599c3bbc7759bc861f587
|
|
4
|
+
data.tar.gz: 79c140402e129ae87e72525fee18ca125dfe0d2720ea6455c9b88b926996a19a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 5eb667ddcbf921baaa2f7b92cd618b40e8bf639fe344c9df3b696c8aee5f81abd9e611ca6c5e1f7cc671a894509ccabfa296584a319e28493bf4e9a22dcc7b30
|
|
7
|
+
data.tar.gz: c644e0a39ef9e67ff612f0d640617ab22ebfe4fa46a3062178e44d8dad1d6d5d86457ecba53ab5664b965f86e8fd981fb7b19163765026be1eb7b51ec992608a
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,41 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [1.4.0] - 2026-02-06
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- Add/remove animations with CSS transitions
|
|
14
|
+
- `data-nested-form-animation-value` - animation type: `"fade"`, `"slide"`, or `""` (none)
|
|
15
|
+
- `data-nested-form-animation-duration-value` - duration in ms (default: 300)
|
|
16
|
+
- CSS classes: `nested-form-enter`, `nested-form-enter-active`, `nested-form-exit-active`
|
|
17
|
+
- Optional animation stylesheet: `hotwire_nested_form/animations.css`
|
|
18
|
+
- Install with: `rails g hotwire_nested_form:install --animations`
|
|
19
|
+
- NPM users: `import "hotwire-nested-form-stimulus/css/animations.css"`
|
|
20
|
+
- Deep nesting (multi-level nested forms)
|
|
21
|
+
- Association-specific placeholders (`NEW_TASK_RECORD`, `NEW_SUBTASK_RECORD`) prevent collisions
|
|
22
|
+
- Each `link_to_add_association` automatically generates unique placeholders per association
|
|
23
|
+
- `<template>` tags for template storage (replaces `data-template` attribute for reliable deep nesting)
|
|
24
|
+
- Full backward compatibility - single-level forms work unchanged
|
|
25
|
+
|
|
26
|
+
### Changed
|
|
27
|
+
- Template HTML now stored in `<template>` tags instead of `data-template` attributes
|
|
28
|
+
- `link_to_add_association` outputs `<template>` + `<a>` tag pair
|
|
29
|
+
- Controller `remove()` refactored into `remove()` + `removeElement()` for animation support
|
|
30
|
+
- Added `getTemplate()` method for flexible template lookup
|
|
31
|
+
|
|
32
|
+
## [1.3.0] - 2026-02-06
|
|
33
|
+
|
|
34
|
+
### Added
|
|
35
|
+
- Drag & drop sorting for nested items (requires SortableJS)
|
|
36
|
+
- `data-nested-form-sortable-value` - enable sorting
|
|
37
|
+
- `data-nested-form-position-field-value` - custom position field name
|
|
38
|
+
- `data-nested-form-sort-handle-value` - CSS selector for drag handle
|
|
39
|
+
- New events: `nested-form:before-sort` and `nested-form:after-sort`
|
|
40
|
+
- CSS classes for drag styling: `nested-form-dragging`, `nested-form-drag-ghost`
|
|
41
|
+
|
|
42
|
+
### Changed
|
|
43
|
+
- Controller now cleans up Sortable instance on disconnect
|
|
44
|
+
|
|
10
45
|
## [1.2.0] - 2026-02-05
|
|
11
46
|
|
|
12
47
|
### Added
|
data/Gemfile.lock
CHANGED
data/README.md
CHANGED
|
@@ -180,6 +180,215 @@ 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
|
+
|
|
275
|
+
## Animations
|
|
276
|
+
|
|
277
|
+
Add smooth CSS transitions when items are added or removed:
|
|
278
|
+
|
|
279
|
+
```erb
|
|
280
|
+
<div data-controller="nested-form"
|
|
281
|
+
data-nested-form-animation-value="fade"
|
|
282
|
+
data-nested-form-animation-duration-value="300">
|
|
283
|
+
<!-- nested fields -->
|
|
284
|
+
</div>
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
### Include the Animation Stylesheet
|
|
288
|
+
|
|
289
|
+
**Rails (generator):**
|
|
290
|
+
```bash
|
|
291
|
+
rails g hotwire_nested_form:install --animations
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
**Rails (manual):** Add to your stylesheet:
|
|
295
|
+
```css
|
|
296
|
+
@import "hotwire_nested_form/animations";
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
**NPM:**
|
|
300
|
+
```javascript
|
|
301
|
+
import "hotwire-nested-form-stimulus/css/animations.css"
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
### Animation Options
|
|
305
|
+
|
|
306
|
+
| Attribute | Default | Description |
|
|
307
|
+
|-----------|---------|-------------|
|
|
308
|
+
| `data-nested-form-animation-value` | `""` | `"fade"`, `"slide"`, or `""` (none) |
|
|
309
|
+
| `data-nested-form-animation-duration-value` | `300` | Duration in milliseconds |
|
|
310
|
+
|
|
311
|
+
### CSS Classes
|
|
312
|
+
|
|
313
|
+
| Class | When Applied |
|
|
314
|
+
|-------|-------------|
|
|
315
|
+
| `nested-form-enter` | Immediately on add |
|
|
316
|
+
| `nested-form-enter-active` | Next frame after add (triggers transition) |
|
|
317
|
+
| `nested-form-exit-active` | On remove (triggers transition, then element is hidden/removed) |
|
|
318
|
+
|
|
319
|
+
You can customize the animations by overriding these classes in your stylesheet.
|
|
320
|
+
|
|
321
|
+
## Deep Nesting (Multi-Level)
|
|
322
|
+
|
|
323
|
+
Nest forms inside forms (e.g. Project -> Tasks -> Subtasks):
|
|
324
|
+
|
|
325
|
+
### 1. Model Setup
|
|
326
|
+
|
|
327
|
+
```ruby
|
|
328
|
+
class Project < ApplicationRecord
|
|
329
|
+
has_many :tasks, dependent: :destroy
|
|
330
|
+
accepts_nested_attributes_for :tasks, allow_destroy: true
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
class Task < ApplicationRecord
|
|
334
|
+
belongs_to :project
|
|
335
|
+
has_many :subtasks, dependent: :destroy
|
|
336
|
+
accepts_nested_attributes_for :subtasks, allow_destroy: true
|
|
337
|
+
end
|
|
338
|
+
```
|
|
339
|
+
|
|
340
|
+
### 2. Form Setup
|
|
341
|
+
|
|
342
|
+
```erb
|
|
343
|
+
<%# _form.html.erb %>
|
|
344
|
+
<%= form_with model: @project do |f| %>
|
|
345
|
+
<div data-controller="nested-form">
|
|
346
|
+
<div id="tasks">
|
|
347
|
+
<%= f.fields_for :tasks do |tf| %>
|
|
348
|
+
<%= render "task_fields", f: tf %>
|
|
349
|
+
<% end %>
|
|
350
|
+
</div>
|
|
351
|
+
<%= link_to_add_association "Add Task", f, :tasks,
|
|
352
|
+
insertion: :append, target: "#tasks" %>
|
|
353
|
+
</div>
|
|
354
|
+
<%= f.submit %>
|
|
355
|
+
<% end %>
|
|
356
|
+
|
|
357
|
+
<%# _task_fields.html.erb %>
|
|
358
|
+
<div class="nested-fields">
|
|
359
|
+
<%= f.text_field :name %>
|
|
360
|
+
<%= link_to_remove_association "Remove Task", f %>
|
|
361
|
+
|
|
362
|
+
<div data-controller="nested-form">
|
|
363
|
+
<div id="subtasks">
|
|
364
|
+
<%= f.fields_for :subtasks do |sf| %>
|
|
365
|
+
<%= render "subtask_fields", f: sf %>
|
|
366
|
+
<% end %>
|
|
367
|
+
</div>
|
|
368
|
+
<%= link_to_add_association "Add Subtask", f, :subtasks,
|
|
369
|
+
insertion: :append, target: "#subtasks" %>
|
|
370
|
+
</div>
|
|
371
|
+
</div>
|
|
372
|
+
|
|
373
|
+
<%# _subtask_fields.html.erb %>
|
|
374
|
+
<div class="nested-fields">
|
|
375
|
+
<%= f.text_field :name %>
|
|
376
|
+
<%= link_to_remove_association "Remove", f %>
|
|
377
|
+
</div>
|
|
378
|
+
```
|
|
379
|
+
|
|
380
|
+
### 3. Controller Params
|
|
381
|
+
|
|
382
|
+
```ruby
|
|
383
|
+
def project_params
|
|
384
|
+
params.require(:project).permit(:name,
|
|
385
|
+
tasks_attributes: [:id, :name, :_destroy,
|
|
386
|
+
subtasks_attributes: [:id, :name, :_destroy]])
|
|
387
|
+
end
|
|
388
|
+
```
|
|
389
|
+
|
|
390
|
+
Each nesting level automatically gets a unique placeholder (`NEW_TASK_RECORD`, `NEW_SUBTASK_RECORD`) so adding items at one level doesn't affect templates at other levels. Each `data-controller="nested-form"` operates independently.
|
|
391
|
+
|
|
183
392
|
## NPM Package (JavaScript-only)
|
|
184
393
|
|
|
185
394
|
For non-Rails projects using Stimulus, install via npm:
|
|
@@ -276,6 +485,8 @@ link_to_remove_association(name, form, options = {}, &block)
|
|
|
276
485
|
| `nested-form:after-remove` | No | `{ wrapper }` | After fields removed |
|
|
277
486
|
| `nested-form:limit-reached` | No | `{ limit, current }` | When max limit reached |
|
|
278
487
|
| `nested-form:minimum-reached` | No | `{ minimum, current }` | When min limit reached |
|
|
488
|
+
| `nested-form:before-sort` | Yes | `{ item, oldIndex }` | Before drag starts |
|
|
489
|
+
| `nested-form:after-sort` | No | `{ item, oldIndex, newIndex }` | After drop completes |
|
|
279
490
|
|
|
280
491
|
**Usage Examples:**
|
|
281
492
|
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* hotwire_nested_form - Animation Styles
|
|
3
|
+
*
|
|
4
|
+
* Include this stylesheet for smooth add/remove transitions.
|
|
5
|
+
* Enable with: data-nested-form-animation-value="fade" or "slide"
|
|
6
|
+
*
|
|
7
|
+
* Rails: Include in your application stylesheet or copy to app/assets/stylesheets/
|
|
8
|
+
* NPM: import "hotwire-nested-form/css/animations.css"
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/* Fade animation (default) */
|
|
12
|
+
.nested-form-enter {
|
|
13
|
+
opacity: 0;
|
|
14
|
+
transform: translateY(-10px);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
.nested-form-enter-active {
|
|
18
|
+
opacity: 1;
|
|
19
|
+
transform: translateY(0);
|
|
20
|
+
transition: opacity 300ms ease, transform 300ms ease;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
.nested-form-exit-active {
|
|
24
|
+
opacity: 0;
|
|
25
|
+
transform: translateY(-10px);
|
|
26
|
+
transition: opacity 300ms ease, transform 300ms ease;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/* Slide animation */
|
|
30
|
+
.nested-form-enter[data-animation="slide"] {
|
|
31
|
+
max-height: 0;
|
|
32
|
+
overflow: hidden;
|
|
33
|
+
opacity: 0;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
.nested-form-enter-active[data-animation="slide"] {
|
|
37
|
+
max-height: 500px;
|
|
38
|
+
opacity: 1;
|
|
39
|
+
transition: max-height 300ms ease, opacity 300ms ease;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
.nested-form-exit-active[data-animation="slide"] {
|
|
43
|
+
max-height: 0;
|
|
44
|
+
overflow: hidden;
|
|
45
|
+
opacity: 0;
|
|
46
|
+
transition: max-height 300ms ease, opacity 300ms ease;
|
|
47
|
+
}
|
|
@@ -7,11 +7,21 @@ module HotwireNestedForm
|
|
|
7
7
|
|
|
8
8
|
desc 'Install hotwire_nested_form'
|
|
9
9
|
|
|
10
|
+
class_option :animations, type: :boolean, default: false,
|
|
11
|
+
desc: 'Copy animation stylesheet'
|
|
12
|
+
|
|
10
13
|
def copy_stimulus_controller
|
|
11
14
|
copy_file 'nested_form_controller.js',
|
|
12
15
|
'app/javascript/controllers/nested_form_controller.js'
|
|
13
16
|
end
|
|
14
17
|
|
|
18
|
+
def copy_animation_stylesheet
|
|
19
|
+
return unless options[:animations]
|
|
20
|
+
|
|
21
|
+
copy_file 'animations.css',
|
|
22
|
+
'app/assets/stylesheets/hotwire_nested_form/animations.css'
|
|
23
|
+
end
|
|
24
|
+
|
|
15
25
|
def show_post_install_message
|
|
16
26
|
say ''
|
|
17
27
|
say '=' * 60
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* hotwire_nested_form - Animation Styles
|
|
3
|
+
*
|
|
4
|
+
* Include this stylesheet for smooth add/remove transitions.
|
|
5
|
+
* Enable with: data-nested-form-animation-value="fade" or "slide"
|
|
6
|
+
*
|
|
7
|
+
* Rails: Include in your application stylesheet or copy to app/assets/stylesheets/
|
|
8
|
+
* NPM: import "hotwire-nested-form/css/animations.css"
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/* Fade animation (default) */
|
|
12
|
+
.nested-form-enter {
|
|
13
|
+
opacity: 0;
|
|
14
|
+
transform: translateY(-10px);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
.nested-form-enter-active {
|
|
18
|
+
opacity: 1;
|
|
19
|
+
transform: translateY(0);
|
|
20
|
+
transition: opacity 300ms ease, transform 300ms ease;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
.nested-form-exit-active {
|
|
24
|
+
opacity: 0;
|
|
25
|
+
transform: translateY(-10px);
|
|
26
|
+
transition: opacity 300ms ease, transform 300ms ease;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/* Slide animation */
|
|
30
|
+
.nested-form-enter[data-animation="slide"] {
|
|
31
|
+
max-height: 0;
|
|
32
|
+
overflow: hidden;
|
|
33
|
+
opacity: 0;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
.nested-form-enter-active[data-animation="slide"] {
|
|
37
|
+
max-height: 500px;
|
|
38
|
+
opacity: 1;
|
|
39
|
+
transition: max-height 300ms ease, opacity 300ms ease;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
.nested-form-exit-active[data-animation="slide"] {
|
|
43
|
+
max-height: 0;
|
|
44
|
+
overflow: hidden;
|
|
45
|
+
opacity: 0;
|
|
46
|
+
transition: max-height 300ms ease, opacity 300ms ease;
|
|
47
|
+
}
|
|
@@ -5,11 +5,21 @@ 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: "" },
|
|
12
|
+
animation: { type: String, default: "" },
|
|
13
|
+
animationDuration: { type: Number, default: 300 }
|
|
9
14
|
}
|
|
10
15
|
|
|
11
16
|
connect() {
|
|
12
17
|
this.updateButtonStates()
|
|
18
|
+
if (this.sortableValue) this.initializeSortable()
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
disconnect() {
|
|
22
|
+
if (this.sortableInstance) this.sortableInstance.destroy()
|
|
13
23
|
}
|
|
14
24
|
|
|
15
25
|
get currentCount() {
|
|
@@ -34,7 +44,7 @@ export default class extends Controller {
|
|
|
34
44
|
return
|
|
35
45
|
}
|
|
36
46
|
|
|
37
|
-
const template = event.currentTarget
|
|
47
|
+
const template = this.getTemplate(event.currentTarget)
|
|
38
48
|
const insertion = event.currentTarget.dataset.insertion || "before"
|
|
39
49
|
const targetSelector = event.currentTarget.dataset.target
|
|
40
50
|
const count = parseInt(event.currentTarget.dataset.count) || 1
|
|
@@ -45,6 +55,7 @@ export default class extends Controller {
|
|
|
45
55
|
}
|
|
46
56
|
|
|
47
57
|
this.updateButtonStates()
|
|
58
|
+
if (this.sortableValue && !this.sortableInstance) this.initializeSortable()
|
|
48
59
|
}
|
|
49
60
|
|
|
50
61
|
remove(event) {
|
|
@@ -67,6 +78,14 @@ export default class extends Controller {
|
|
|
67
78
|
|
|
68
79
|
if (beforeEvent.defaultPrevented) return
|
|
69
80
|
|
|
81
|
+
if (this.animationValue) {
|
|
82
|
+
this.animateOut(wrapper, () => this.removeElement(wrapper))
|
|
83
|
+
} else {
|
|
84
|
+
this.removeElement(wrapper)
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
removeElement(wrapper) {
|
|
70
89
|
const destroyInput = wrapper.querySelector("input[name*='_destroy']")
|
|
71
90
|
|
|
72
91
|
if (destroyInput) {
|
|
@@ -80,9 +99,23 @@ export default class extends Controller {
|
|
|
80
99
|
this.updateButtonStates()
|
|
81
100
|
}
|
|
82
101
|
|
|
102
|
+
getTemplate(trigger) {
|
|
103
|
+
// Prefer <template> tag (handles deep nesting), fall back to data-template
|
|
104
|
+
const placeholder = trigger.dataset.placeholder
|
|
105
|
+
if (placeholder) {
|
|
106
|
+
const templateEl = this.element.querySelector(
|
|
107
|
+
`template[data-nested-form-template="${placeholder}"]`
|
|
108
|
+
)
|
|
109
|
+
if (templateEl) return templateEl.innerHTML
|
|
110
|
+
}
|
|
111
|
+
return trigger.dataset.template
|
|
112
|
+
}
|
|
113
|
+
|
|
83
114
|
insertFields(template, insertion, targetSelector, trigger) {
|
|
84
115
|
const newId = new Date().getTime()
|
|
85
|
-
const
|
|
116
|
+
const placeholder = trigger.dataset.placeholder || "NEW_RECORD"
|
|
117
|
+
const regex = new RegExp(placeholder, "g")
|
|
118
|
+
const content = template.replace(regex, newId)
|
|
86
119
|
|
|
87
120
|
const fragment = document.createRange().createContextualFragment(content)
|
|
88
121
|
const wrapper = fragment.firstElementChild
|
|
@@ -113,6 +146,37 @@ export default class extends Controller {
|
|
|
113
146
|
}
|
|
114
147
|
|
|
115
148
|
this.dispatch("after-add", { detail: { wrapper } })
|
|
149
|
+
|
|
150
|
+
if (this.animationValue) {
|
|
151
|
+
this.animateIn(wrapper)
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Animations
|
|
156
|
+
|
|
157
|
+
animateIn(element) {
|
|
158
|
+
element.classList.add("nested-form-enter")
|
|
159
|
+
requestAnimationFrame(() => {
|
|
160
|
+
requestAnimationFrame(() => {
|
|
161
|
+
element.classList.add("nested-form-enter-active")
|
|
162
|
+
setTimeout(() => {
|
|
163
|
+
element.classList.remove("nested-form-enter", "nested-form-enter-active")
|
|
164
|
+
}, this.animationDurationValue)
|
|
165
|
+
})
|
|
166
|
+
})
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
animateOut(element, callback) {
|
|
170
|
+
element.classList.add("nested-form-exit-active")
|
|
171
|
+
|
|
172
|
+
const done = () => {
|
|
173
|
+
element.classList.remove("nested-form-exit-active")
|
|
174
|
+
callback()
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
element.addEventListener("transitionend", done, { once: true })
|
|
178
|
+
// Fallback if transitionend doesn't fire
|
|
179
|
+
setTimeout(done, this.animationDurationValue + 50)
|
|
116
180
|
}
|
|
117
181
|
|
|
118
182
|
updateButtonStates() {
|
|
@@ -146,4 +210,79 @@ export default class extends Controller {
|
|
|
146
210
|
button.style.display = ""
|
|
147
211
|
}
|
|
148
212
|
}
|
|
213
|
+
|
|
214
|
+
// Drag & Drop Sorting
|
|
215
|
+
|
|
216
|
+
initializeSortable() {
|
|
217
|
+
if (typeof Sortable === 'undefined') {
|
|
218
|
+
console.warn('hotwire_nested_form: SortableJS not found. Install it for drag & drop sorting: https://sortablejs.github.io/Sortable/')
|
|
219
|
+
return
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const container = this.findSortableContainer()
|
|
223
|
+
if (!container) return
|
|
224
|
+
|
|
225
|
+
this.sortableInstance = Sortable.create(container, {
|
|
226
|
+
animation: 150,
|
|
227
|
+
handle: this.sortHandleValue || null,
|
|
228
|
+
draggable: `.${this.wrapperClassValue}`,
|
|
229
|
+
ghostClass: 'nested-form-drag-ghost',
|
|
230
|
+
chosenClass: 'nested-form-dragging',
|
|
231
|
+
onStart: (evt) => this.onSortStart(evt),
|
|
232
|
+
onEnd: (evt) => this.onSortEnd(evt)
|
|
233
|
+
})
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
findSortableContainer() {
|
|
237
|
+
// Look for common container patterns
|
|
238
|
+
const selectors = ['#tasks', '#items', '[data-nested-form-target="container"]']
|
|
239
|
+
for (const selector of selectors) {
|
|
240
|
+
const container = this.element.querySelector(selector)
|
|
241
|
+
if (container) return container
|
|
242
|
+
}
|
|
243
|
+
// Fallback: find first element containing nested-fields
|
|
244
|
+
const firstField = this.element.querySelector(`.${this.wrapperClassValue}`)
|
|
245
|
+
return firstField ? firstField.parentElement : this.element
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
onSortStart(evt) {
|
|
249
|
+
const beforeEvent = this.dispatch("before-sort", {
|
|
250
|
+
cancelable: true,
|
|
251
|
+
detail: { item: evt.item, oldIndex: evt.oldIndex }
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
if (beforeEvent.defaultPrevented) {
|
|
255
|
+
this.sortableInstance.option("disabled", true)
|
|
256
|
+
setTimeout(() => this.sortableInstance.option("disabled", false), 0)
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
onSortEnd(evt) {
|
|
261
|
+
this.updatePositions()
|
|
262
|
+
|
|
263
|
+
this.dispatch("after-sort", {
|
|
264
|
+
detail: {
|
|
265
|
+
item: evt.item,
|
|
266
|
+
oldIndex: evt.oldIndex,
|
|
267
|
+
newIndex: evt.newIndex
|
|
268
|
+
}
|
|
269
|
+
})
|
|
270
|
+
|
|
271
|
+
this.updateButtonStates()
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
updatePositions() {
|
|
275
|
+
const items = this.element.querySelectorAll(
|
|
276
|
+
`.${this.wrapperClassValue}:not([style*="display: none"])`
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
items.forEach((item, index) => {
|
|
280
|
+
const positionInput = item.querySelector(
|
|
281
|
+
`input[name*="[${this.positionFieldValue}]"]`
|
|
282
|
+
)
|
|
283
|
+
if (positionInput) {
|
|
284
|
+
positionInput.value = index + 1
|
|
285
|
+
}
|
|
286
|
+
})
|
|
287
|
+
}
|
|
149
288
|
}
|
|
@@ -42,32 +42,42 @@ module HotwireNestedForm
|
|
|
42
42
|
insertion = options.delete(:insertion) || :before
|
|
43
43
|
target = options.delete(:target)
|
|
44
44
|
|
|
45
|
+
# Generate association-specific placeholder for deep nesting
|
|
46
|
+
placeholder = "NEW_#{association.to_s.upcase.singularize}_RECORD"
|
|
47
|
+
|
|
45
48
|
# Build the template
|
|
46
49
|
template = build_association_template(
|
|
47
50
|
form,
|
|
48
51
|
association,
|
|
49
52
|
partial: partial,
|
|
50
53
|
render_options: render_options,
|
|
51
|
-
wrap_object: wrap_object
|
|
54
|
+
wrap_object: wrap_object,
|
|
55
|
+
placeholder: placeholder
|
|
52
56
|
)
|
|
53
57
|
|
|
54
|
-
# Build data attributes
|
|
58
|
+
# Build data attributes for the link
|
|
55
59
|
data = options[:data] || {}
|
|
56
60
|
data[:action] = 'nested-form#add'
|
|
57
|
-
data[:template] = template
|
|
58
61
|
data[:insertion] = insertion
|
|
59
62
|
data[:count] = count if count > 1
|
|
60
63
|
data[:target] = target if target
|
|
64
|
+
data[:placeholder] = placeholder
|
|
61
65
|
|
|
62
66
|
options[:data] = data
|
|
63
67
|
options[:href] = '#'
|
|
64
68
|
|
|
65
|
-
|
|
69
|
+
# Use <template> tag to store the HTML template (handles deep nesting)
|
|
70
|
+
# The template content is generated by Rails' fields_for/render, not user input
|
|
71
|
+
template_tag = content_tag(:template, template.html_safe, # rubocop:disable Rails/OutputSafety
|
|
72
|
+
data: { nested_form_template: placeholder })
|
|
73
|
+
link_tag = content_tag(:a, name, options)
|
|
74
|
+
|
|
75
|
+
template_tag + link_tag
|
|
66
76
|
end
|
|
67
77
|
|
|
68
78
|
private
|
|
69
79
|
|
|
70
|
-
def build_association_template(form, association, partial:, render_options:, wrap_object:)
|
|
80
|
+
def build_association_template(form, association, partial:, render_options:, wrap_object:, placeholder:)
|
|
71
81
|
# Get the association reflection
|
|
72
82
|
reflection = form.object.class.reflect_on_association(association)
|
|
73
83
|
raise ArgumentError, "Association #{association} not found" unless reflection
|
|
@@ -78,10 +88,8 @@ module HotwireNestedForm
|
|
|
78
88
|
# Determine partial name
|
|
79
89
|
partial_name = partial || "#{association.to_s.singularize}_fields"
|
|
80
90
|
|
|
81
|
-
#
|
|
82
|
-
|
|
83
|
-
# SimpleForm overrides fields_for to use simple_fields_for internally
|
|
84
|
-
form.fields_for(association, new_object, child_index: 'NEW_RECORD') do |builder|
|
|
91
|
+
# Use association-specific placeholder for deep nesting support
|
|
92
|
+
form.fields_for(association, new_object, child_index: placeholder) do |builder|
|
|
85
93
|
locals = (render_options[:locals] || {}).merge(f: builder)
|
|
86
94
|
render(partial: partial_name, locals: locals)
|
|
87
95
|
end
|
data/npm/README.md
CHANGED
|
@@ -30,23 +30,33 @@ application.register("nested-form", NestedFormController)
|
|
|
30
30
|
<!-- Existing nested fields go here -->
|
|
31
31
|
</div>
|
|
32
32
|
|
|
33
|
+
<template data-nested-form-template="NEW_ITEM_RECORD">
|
|
34
|
+
<div class="nested-fields">
|
|
35
|
+
<input name="items[NEW_ITEM_RECORD][name]">
|
|
36
|
+
<a href="#" data-action="nested-form#remove">Remove</a>
|
|
37
|
+
</div>
|
|
38
|
+
</template>
|
|
33
39
|
<a href="#"
|
|
34
40
|
data-action="nested-form#add"
|
|
35
|
-
data-
|
|
41
|
+
data-placeholder="NEW_ITEM_RECORD"
|
|
42
|
+
data-insertion="append"
|
|
43
|
+
data-target="#items">
|
|
36
44
|
Add Item
|
|
37
45
|
</a>
|
|
38
46
|
</div>
|
|
39
47
|
```
|
|
40
48
|
|
|
41
|
-
### Data Attributes
|
|
49
|
+
### Data Attributes (on add button)
|
|
42
50
|
|
|
43
51
|
| Attribute | Description | Default |
|
|
44
52
|
|-----------|-------------|---------|
|
|
45
|
-
| `data-
|
|
53
|
+
| `data-placeholder` | Placeholder string in template to replace with unique ID | `"NEW_RECORD"` |
|
|
46
54
|
| `data-insertion` | Where to insert: `before`, `after`, `append`, `prepend` | `before` |
|
|
47
55
|
| `data-count` | Number of fields to add per click | `1` |
|
|
48
56
|
| `data-target` | CSS selector for insertion container | Parent element |
|
|
49
57
|
|
|
58
|
+
**Note:** For backward compatibility, `data-template` (inline HTML) is still supported, but `<template>` tags are recommended for deep nesting support.
|
|
59
|
+
|
|
50
60
|
### Min/Max Limits
|
|
51
61
|
|
|
52
62
|
```html
|
|
@@ -64,6 +74,98 @@ application.register("nested-form", NestedFormController)
|
|
|
64
74
|
| `data-nested-form-max-value` | Maximum items allowed | unlimited |
|
|
65
75
|
| `data-nested-form-limit-behavior-value` | `"disable"`, `"hide"`, or `"error"` | `"disable"` |
|
|
66
76
|
|
|
77
|
+
### Drag & Drop Sorting
|
|
78
|
+
|
|
79
|
+
Requires [SortableJS](https://sortablejs.github.io/Sortable/):
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
npm install sortablejs
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
```javascript
|
|
86
|
+
import Sortable from 'sortablejs'
|
|
87
|
+
window.Sortable = Sortable
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
```html
|
|
91
|
+
<div data-controller="nested-form"
|
|
92
|
+
data-nested-form-sortable-value="true"
|
|
93
|
+
data-nested-form-sort-handle-value=".drag-handle">
|
|
94
|
+
|
|
95
|
+
<div id="items">
|
|
96
|
+
<div class="nested-fields">
|
|
97
|
+
<input type="hidden" name="items[][position]" value="1">
|
|
98
|
+
<span class="drag-handle">☰</span>
|
|
99
|
+
<!-- other fields -->
|
|
100
|
+
</div>
|
|
101
|
+
</div>
|
|
102
|
+
</div>
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
| Attribute | Default | Description |
|
|
106
|
+
|-----------|---------|-------------|
|
|
107
|
+
| `data-nested-form-sortable-value` | `false` | Enable sorting |
|
|
108
|
+
| `data-nested-form-position-field-value` | `"position"` | Position field name |
|
|
109
|
+
| `data-nested-form-sort-handle-value` | (none) | Drag handle selector |
|
|
110
|
+
|
|
111
|
+
### Animations
|
|
112
|
+
|
|
113
|
+
Add smooth CSS transitions when items are added or removed:
|
|
114
|
+
|
|
115
|
+
```javascript
|
|
116
|
+
import "hotwire-nested-form-stimulus/css/animations.css"
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
```html
|
|
120
|
+
<div data-controller="nested-form"
|
|
121
|
+
data-nested-form-animation-value="fade"
|
|
122
|
+
data-nested-form-animation-duration-value="300">
|
|
123
|
+
<!-- fields here -->
|
|
124
|
+
</div>
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
| Attribute | Default | Description |
|
|
128
|
+
|-----------|---------|-------------|
|
|
129
|
+
| `data-nested-form-animation-value` | `""` | `"fade"`, `"slide"`, or `""` (none) |
|
|
130
|
+
| `data-nested-form-animation-duration-value` | `300` | Duration in milliseconds |
|
|
131
|
+
|
|
132
|
+
### Deep Nesting
|
|
133
|
+
|
|
134
|
+
For multi-level nesting, use `<template>` tags and `data-placeholder` attributes. Each nesting level needs its own `data-controller="nested-form"` and a unique placeholder:
|
|
135
|
+
|
|
136
|
+
```html
|
|
137
|
+
<div data-controller="nested-form">
|
|
138
|
+
<div id="tasks">
|
|
139
|
+
<!-- task items here -->
|
|
140
|
+
</div>
|
|
141
|
+
|
|
142
|
+
<template data-nested-form-template="NEW_TASK_RECORD">
|
|
143
|
+
<div class="nested-fields">
|
|
144
|
+
<input name="items[tasks][NEW_TASK_RECORD][name]">
|
|
145
|
+
|
|
146
|
+
<!-- Nested level 2 -->
|
|
147
|
+
<div data-controller="nested-form">
|
|
148
|
+
<div id="subtasks"></div>
|
|
149
|
+
<template data-nested-form-template="NEW_SUBTASK_RECORD">
|
|
150
|
+
<div class="nested-fields">
|
|
151
|
+
<input name="items[tasks][NEW_TASK_RECORD][subtasks][NEW_SUBTASK_RECORD][name]">
|
|
152
|
+
<a href="#" data-action="nested-form#remove">Remove</a>
|
|
153
|
+
</div>
|
|
154
|
+
</template>
|
|
155
|
+
<a href="#" data-action="nested-form#add"
|
|
156
|
+
data-placeholder="NEW_SUBTASK_RECORD"
|
|
157
|
+
data-insertion="append" data-target="#subtasks">Add Subtask</a>
|
|
158
|
+
</div>
|
|
159
|
+
</div>
|
|
160
|
+
</template>
|
|
161
|
+
<a href="#" data-action="nested-form#add"
|
|
162
|
+
data-placeholder="NEW_TASK_RECORD"
|
|
163
|
+
data-insertion="append" data-target="#tasks">Add Task</a>
|
|
164
|
+
</div>
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
The controller replaces only the matching placeholder per button, so nested templates stay intact.
|
|
168
|
+
|
|
67
169
|
### Events
|
|
68
170
|
|
|
69
171
|
| Event | Cancelable | Detail |
|
|
@@ -74,6 +176,8 @@ application.register("nested-form", NestedFormController)
|
|
|
74
176
|
| `nested-form:after-remove` | No | `{ wrapper }` |
|
|
75
177
|
| `nested-form:limit-reached` | No | `{ limit, current }` |
|
|
76
178
|
| `nested-form:minimum-reached` | No | `{ minimum, current }` |
|
|
179
|
+
| `nested-form:before-sort` | Yes | `{ item, oldIndex }` |
|
|
180
|
+
| `nested-form:after-sort` | No | `{ item, oldIndex, newIndex }` |
|
|
77
181
|
|
|
78
182
|
### Example: Listen for Events
|
|
79
183
|
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* hotwire-nested-form - Animation Styles
|
|
3
|
+
*
|
|
4
|
+
* Include this stylesheet for smooth add/remove transitions.
|
|
5
|
+
* Enable with: data-nested-form-animation-value="fade" or "slide"
|
|
6
|
+
*
|
|
7
|
+
* Import: import "hotwire-nested-form/css/animations.css"
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/* Fade animation (default) */
|
|
11
|
+
.nested-form-enter {
|
|
12
|
+
opacity: 0;
|
|
13
|
+
transform: translateY(-10px);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
.nested-form-enter-active {
|
|
17
|
+
opacity: 1;
|
|
18
|
+
transform: translateY(0);
|
|
19
|
+
transition: opacity 300ms ease, transform 300ms ease;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
.nested-form-exit-active {
|
|
23
|
+
opacity: 0;
|
|
24
|
+
transform: translateY(-10px);
|
|
25
|
+
transition: opacity 300ms ease, transform 300ms ease;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/* Slide animation */
|
|
29
|
+
.nested-form-enter[data-animation="slide"] {
|
|
30
|
+
max-height: 0;
|
|
31
|
+
overflow: hidden;
|
|
32
|
+
opacity: 0;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
.nested-form-enter-active[data-animation="slide"] {
|
|
36
|
+
max-height: 500px;
|
|
37
|
+
opacity: 1;
|
|
38
|
+
transition: max-height 300ms ease, opacity 300ms ease;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
.nested-form-exit-active[data-animation="slide"] {
|
|
42
|
+
max-height: 0;
|
|
43
|
+
overflow: hidden;
|
|
44
|
+
opacity: 0;
|
|
45
|
+
transition: max-height 300ms ease, opacity 300ms ease;
|
|
46
|
+
}
|
data/npm/package.json
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hotwire-nested-form-stimulus",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.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",
|
|
7
7
|
"type": "module",
|
|
8
8
|
"files": [
|
|
9
|
-
"src"
|
|
9
|
+
"src",
|
|
10
|
+
"css"
|
|
10
11
|
],
|
|
11
12
|
"keywords": [
|
|
12
13
|
"stimulus",
|
|
@@ -5,11 +5,21 @@ 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: "" },
|
|
12
|
+
animation: { type: String, default: "" },
|
|
13
|
+
animationDuration: { type: Number, default: 300 }
|
|
9
14
|
}
|
|
10
15
|
|
|
11
16
|
connect() {
|
|
12
17
|
this.updateButtonStates()
|
|
18
|
+
if (this.sortableValue) this.initializeSortable()
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
disconnect() {
|
|
22
|
+
if (this.sortableInstance) this.sortableInstance.destroy()
|
|
13
23
|
}
|
|
14
24
|
|
|
15
25
|
get currentCount() {
|
|
@@ -34,7 +44,7 @@ export default class extends Controller {
|
|
|
34
44
|
return
|
|
35
45
|
}
|
|
36
46
|
|
|
37
|
-
const template = event.currentTarget
|
|
47
|
+
const template = this.getTemplate(event.currentTarget)
|
|
38
48
|
const insertion = event.currentTarget.dataset.insertion || "before"
|
|
39
49
|
const targetSelector = event.currentTarget.dataset.target
|
|
40
50
|
const count = parseInt(event.currentTarget.dataset.count) || 1
|
|
@@ -45,6 +55,7 @@ export default class extends Controller {
|
|
|
45
55
|
}
|
|
46
56
|
|
|
47
57
|
this.updateButtonStates()
|
|
58
|
+
if (this.sortableValue && !this.sortableInstance) this.initializeSortable()
|
|
48
59
|
}
|
|
49
60
|
|
|
50
61
|
remove(event) {
|
|
@@ -67,6 +78,14 @@ export default class extends Controller {
|
|
|
67
78
|
|
|
68
79
|
if (beforeEvent.defaultPrevented) return
|
|
69
80
|
|
|
81
|
+
if (this.animationValue) {
|
|
82
|
+
this.animateOut(wrapper, () => this.removeElement(wrapper))
|
|
83
|
+
} else {
|
|
84
|
+
this.removeElement(wrapper)
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
removeElement(wrapper) {
|
|
70
89
|
const destroyInput = wrapper.querySelector("input[name*='_destroy']")
|
|
71
90
|
|
|
72
91
|
if (destroyInput) {
|
|
@@ -80,9 +99,23 @@ export default class extends Controller {
|
|
|
80
99
|
this.updateButtonStates()
|
|
81
100
|
}
|
|
82
101
|
|
|
102
|
+
getTemplate(trigger) {
|
|
103
|
+
// Prefer <template> tag (handles deep nesting), fall back to data-template
|
|
104
|
+
const placeholder = trigger.dataset.placeholder
|
|
105
|
+
if (placeholder) {
|
|
106
|
+
const templateEl = this.element.querySelector(
|
|
107
|
+
`template[data-nested-form-template="${placeholder}"]`
|
|
108
|
+
)
|
|
109
|
+
if (templateEl) return templateEl.innerHTML
|
|
110
|
+
}
|
|
111
|
+
return trigger.dataset.template
|
|
112
|
+
}
|
|
113
|
+
|
|
83
114
|
insertFields(template, insertion, targetSelector, trigger) {
|
|
84
115
|
const newId = new Date().getTime()
|
|
85
|
-
const
|
|
116
|
+
const placeholder = trigger.dataset.placeholder || "NEW_RECORD"
|
|
117
|
+
const regex = new RegExp(placeholder, "g")
|
|
118
|
+
const content = template.replace(regex, newId)
|
|
86
119
|
|
|
87
120
|
const fragment = document.createRange().createContextualFragment(content)
|
|
88
121
|
const wrapper = fragment.firstElementChild
|
|
@@ -113,6 +146,37 @@ export default class extends Controller {
|
|
|
113
146
|
}
|
|
114
147
|
|
|
115
148
|
this.dispatch("after-add", { detail: { wrapper } })
|
|
149
|
+
|
|
150
|
+
if (this.animationValue) {
|
|
151
|
+
this.animateIn(wrapper)
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Animations
|
|
156
|
+
|
|
157
|
+
animateIn(element) {
|
|
158
|
+
element.classList.add("nested-form-enter")
|
|
159
|
+
requestAnimationFrame(() => {
|
|
160
|
+
requestAnimationFrame(() => {
|
|
161
|
+
element.classList.add("nested-form-enter-active")
|
|
162
|
+
setTimeout(() => {
|
|
163
|
+
element.classList.remove("nested-form-enter", "nested-form-enter-active")
|
|
164
|
+
}, this.animationDurationValue)
|
|
165
|
+
})
|
|
166
|
+
})
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
animateOut(element, callback) {
|
|
170
|
+
element.classList.add("nested-form-exit-active")
|
|
171
|
+
|
|
172
|
+
const done = () => {
|
|
173
|
+
element.classList.remove("nested-form-exit-active")
|
|
174
|
+
callback()
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
element.addEventListener("transitionend", done, { once: true })
|
|
178
|
+
// Fallback if transitionend doesn't fire
|
|
179
|
+
setTimeout(done, this.animationDurationValue + 50)
|
|
116
180
|
}
|
|
117
181
|
|
|
118
182
|
updateButtonStates() {
|
|
@@ -146,4 +210,79 @@ export default class extends Controller {
|
|
|
146
210
|
button.style.display = ""
|
|
147
211
|
}
|
|
148
212
|
}
|
|
213
|
+
|
|
214
|
+
// Drag & Drop Sorting
|
|
215
|
+
|
|
216
|
+
initializeSortable() {
|
|
217
|
+
if (typeof Sortable === 'undefined') {
|
|
218
|
+
console.warn('hotwire_nested_form: SortableJS not found. Install it for drag & drop sorting: https://sortablejs.github.io/Sortable/')
|
|
219
|
+
return
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const container = this.findSortableContainer()
|
|
223
|
+
if (!container) return
|
|
224
|
+
|
|
225
|
+
this.sortableInstance = Sortable.create(container, {
|
|
226
|
+
animation: 150,
|
|
227
|
+
handle: this.sortHandleValue || null,
|
|
228
|
+
draggable: `.${this.wrapperClassValue}`,
|
|
229
|
+
ghostClass: 'nested-form-drag-ghost',
|
|
230
|
+
chosenClass: 'nested-form-dragging',
|
|
231
|
+
onStart: (evt) => this.onSortStart(evt),
|
|
232
|
+
onEnd: (evt) => this.onSortEnd(evt)
|
|
233
|
+
})
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
findSortableContainer() {
|
|
237
|
+
// Look for common container patterns
|
|
238
|
+
const selectors = ['#tasks', '#items', '[data-nested-form-target="container"]']
|
|
239
|
+
for (const selector of selectors) {
|
|
240
|
+
const container = this.element.querySelector(selector)
|
|
241
|
+
if (container) return container
|
|
242
|
+
}
|
|
243
|
+
// Fallback: find first element containing nested-fields
|
|
244
|
+
const firstField = this.element.querySelector(`.${this.wrapperClassValue}`)
|
|
245
|
+
return firstField ? firstField.parentElement : this.element
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
onSortStart(evt) {
|
|
249
|
+
const beforeEvent = this.dispatch("before-sort", {
|
|
250
|
+
cancelable: true,
|
|
251
|
+
detail: { item: evt.item, oldIndex: evt.oldIndex }
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
if (beforeEvent.defaultPrevented) {
|
|
255
|
+
this.sortableInstance.option("disabled", true)
|
|
256
|
+
setTimeout(() => this.sortableInstance.option("disabled", false), 0)
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
onSortEnd(evt) {
|
|
261
|
+
this.updatePositions()
|
|
262
|
+
|
|
263
|
+
this.dispatch("after-sort", {
|
|
264
|
+
detail: {
|
|
265
|
+
item: evt.item,
|
|
266
|
+
oldIndex: evt.oldIndex,
|
|
267
|
+
newIndex: evt.newIndex
|
|
268
|
+
}
|
|
269
|
+
})
|
|
270
|
+
|
|
271
|
+
this.updateButtonStates()
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
updatePositions() {
|
|
275
|
+
const items = this.element.querySelectorAll(
|
|
276
|
+
`.${this.wrapperClassValue}:not([style*="display: none"])`
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
items.forEach((item, index) => {
|
|
280
|
+
const positionInput = item.querySelector(
|
|
281
|
+
`input[name*="[${this.positionFieldValue}]"]`
|
|
282
|
+
)
|
|
283
|
+
if (positionInput) {
|
|
284
|
+
positionInput.value = index + 1
|
|
285
|
+
}
|
|
286
|
+
})
|
|
287
|
+
}
|
|
149
288
|
}
|
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.4.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
|
|
@@ -40,10 +40,12 @@ files:
|
|
|
40
40
|
- LICENSE
|
|
41
41
|
- README.md
|
|
42
42
|
- Rakefile
|
|
43
|
+
- app/assets/stylesheets/hotwire_nested_form/animations.css
|
|
43
44
|
- gemfiles/rails_7.0.gemfile
|
|
44
45
|
- gemfiles/rails_7.1.gemfile
|
|
45
46
|
- gemfiles/rails_8.0.gemfile
|
|
46
47
|
- lib/generators/hotwire_nested_form/install_generator.rb
|
|
48
|
+
- lib/generators/hotwire_nested_form/templates/animations.css
|
|
47
49
|
- lib/generators/hotwire_nested_form/templates/nested_form_controller.js
|
|
48
50
|
- lib/hotwire_nested_form.rb
|
|
49
51
|
- lib/hotwire_nested_form/engine.rb
|
|
@@ -54,6 +56,7 @@ files:
|
|
|
54
56
|
- lib/hotwire_nested_form/version.rb
|
|
55
57
|
- npm/.npmignore
|
|
56
58
|
- npm/README.md
|
|
59
|
+
- npm/css/animations.css
|
|
57
60
|
- npm/package.json
|
|
58
61
|
- npm/src/index.js
|
|
59
62
|
- npm/src/nested_form_controller.js
|