hotwire_nested_form 1.0.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: feaf2e256fd22252ab8270a20cfdf0cf77fff4c0937c937be19fa07d6a0dee73
4
+ data.tar.gz: c77e047a69062a5a37f4634cf0950e80f812c0d7af70c72d2df9f63fd827213b
5
+ SHA512:
6
+ metadata.gz: 603c4baba0d02740cd5fa54d2002c5580c16a7fc4e2de3909917d97bd99d9b08976daff5dd74ccc9bd01c40e4a8a9936d0a8f637f1eb05d4a65682295fab1b33
7
+ data.tar.gz: ac9266803d3881bd3794799bf870f95a6c569b796407b8f3318c11b5dc8562b898b41849e614996c18141a1c24c5aab1f6a3a535fa58f6eed9d3fa622e1cdb53
data/.rubocop.yml ADDED
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ plugins:
4
+ - rubocop-rails
5
+ - rubocop-rspec
6
+
7
+ AllCops:
8
+ TargetRubyVersion: 3.1
9
+ NewCops: enable
10
+ SuggestExtensions: false
11
+ Exclude:
12
+ - 'spec/dummy/db/**/*'
13
+ - 'spec/dummy/bin/**/*'
14
+ - 'spec/dummy/tmp/**/*'
15
+ - 'vendor/**/*'
16
+
17
+ # Gem-specific configuration
18
+ Gemspec/DevelopmentDependencies:
19
+ Enabled: false
20
+
21
+ # Style
22
+ Style/Documentation:
23
+ Enabled: false
24
+
25
+ # Layout
26
+ Layout/LineLength:
27
+ Max: 120
28
+ Exclude:
29
+ - 'spec/**/*'
30
+
31
+ # Metrics - relaxed for helper methods which are necessarily complex
32
+ Metrics/BlockLength:
33
+ Exclude:
34
+ - 'spec/**/*'
35
+ - '*.gemspec'
36
+
37
+ Metrics/MethodLength:
38
+ Max: 45
39
+ Exclude:
40
+ - 'lib/generators/**/*'
41
+
42
+ Metrics/AbcSize:
43
+ Max: 50
44
+
45
+ Metrics/CyclomaticComplexity:
46
+ Max: 12
47
+
48
+ Metrics/PerceivedComplexity:
49
+ Max: 12
50
+
51
+ Metrics/ParameterLists:
52
+ Max: 5
53
+ MaxOptionalParameters: 5
54
+ CountKeywordArgs: false
55
+
56
+ # Lint
57
+ Lint/DuplicateBranch:
58
+ Exclude:
59
+ - 'lib/hotwire_nested_form/helpers/add_association.rb'
60
+
61
+ # Naming
62
+ Naming/FileName:
63
+ Exclude:
64
+ - 'Gemfile'
65
+ - 'Rakefile'
66
+
67
+ # Rails
68
+ Rails/FilePath:
69
+ Enabled: false
70
+
71
+ Rails/ApplicationRecord:
72
+ Exclude:
73
+ - 'spec/dummy/app/models/application_record.rb'
74
+
75
+ Rails/ApplicationController:
76
+ Exclude:
77
+ - 'spec/dummy/app/controllers/application_controller.rb'
78
+
79
+ Rails/I18nLocaleTexts:
80
+ Exclude:
81
+ - 'spec/dummy/**/*'
82
+
83
+ # ActionControllerFlashBeforeRender wants params.expect but it doesn't work well with nested attributes
84
+ Rails/ActionControllerFlashBeforeRender:
85
+ Exclude:
86
+ - 'spec/dummy/**/*'
87
+
88
+ # params.expect doesn't work correctly with nested attributes in forms
89
+ Rails/StrongParametersExpect:
90
+ Exclude:
91
+ - 'spec/dummy/**/*'
92
+
93
+ # RSpec
94
+ RSpec/MultipleExpectations:
95
+ Max: 5
96
+
97
+ RSpec/ExampleLength:
98
+ Max: 20
99
+
100
+ RSpec/NestedGroups:
101
+ Max: 4
102
+
103
+ RSpec/DescribeClass:
104
+ Exclude:
105
+ - 'spec/system/**/*'
106
+
107
+ RSpec/SpecFilePathFormat:
108
+ Exclude:
109
+ - 'spec/helpers/**/*'
110
+
111
+ RSpec/LetSetup:
112
+ Exclude:
113
+ - 'spec/system/**/*'
data/.ruby-version ADDED
@@ -0,0 +1 @@
1
+ 3.3.5
data/CHANGELOG.md ADDED
@@ -0,0 +1,26 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ## [1.0.0] - 2026-02-05
11
+
12
+ ### Added
13
+ - Initial release
14
+ - `link_to_add_association` helper for adding nested form fields
15
+ - `link_to_remove_association` helper for removing nested form fields
16
+ - Stimulus controller for add/remove functionality
17
+ - Event system with cancelable events:
18
+ - `nested-form:before-add`
19
+ - `nested-form:after-add`
20
+ - `nested-form:before-remove`
21
+ - `nested-form:after-remove`
22
+ - Support for Rails 7.0, 7.1, and 8.0
23
+ - Support for Ruby 3.1, 3.2, and 3.3
24
+ - Installation generator (`rails g hotwire_nested_form:install`)
25
+ - Full documentation and migration guide from Cocoon
26
+ - Comprehensive test suite (helper specs + system specs)
data/Gemfile ADDED
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ source 'https://rubygems.org'
4
+
5
+ gemspec
6
+
7
+ # Rails version from environment or default
8
+ rails_version = ENV.fetch('RAILS_VERSION', '8.0')
9
+
10
+ gem 'rails', "~> #{rails_version}.0"
11
+
12
+ group :development, :test do
13
+ gem 'capybara', '~> 3.39'
14
+ gem 'puma', '~> 6.0'
15
+ gem 'rspec-rails', '~> 6.0'
16
+ gem 'selenium-webdriver', '~> 4.10'
17
+ gem 'sqlite3', '>= 1.6'
18
+
19
+ # Code quality
20
+ gem 'rubocop', '~> 1.50', require: false
21
+ gem 'rubocop-rails', require: false
22
+ gem 'rubocop-rspec', require: false
23
+
24
+ # Debugging
25
+ gem 'debug', require: false
26
+ end
data/Gemfile.lock ADDED
@@ -0,0 +1,325 @@
1
+ PATH
2
+ remote: .
3
+ specs:
4
+ hotwire_nested_form (1.0.0)
5
+ rails (>= 7.0)
6
+
7
+ GEM
8
+ remote: https://rubygems.org/
9
+ specs:
10
+ actioncable (8.0.4)
11
+ actionpack (= 8.0.4)
12
+ activesupport (= 8.0.4)
13
+ nio4r (~> 2.0)
14
+ websocket-driver (>= 0.6.1)
15
+ zeitwerk (~> 2.6)
16
+ actionmailbox (8.0.4)
17
+ actionpack (= 8.0.4)
18
+ activejob (= 8.0.4)
19
+ activerecord (= 8.0.4)
20
+ activestorage (= 8.0.4)
21
+ activesupport (= 8.0.4)
22
+ mail (>= 2.8.0)
23
+ actionmailer (8.0.4)
24
+ actionpack (= 8.0.4)
25
+ actionview (= 8.0.4)
26
+ activejob (= 8.0.4)
27
+ activesupport (= 8.0.4)
28
+ mail (>= 2.8.0)
29
+ rails-dom-testing (~> 2.2)
30
+ actionpack (8.0.4)
31
+ actionview (= 8.0.4)
32
+ activesupport (= 8.0.4)
33
+ nokogiri (>= 1.8.5)
34
+ rack (>= 2.2.4)
35
+ rack-session (>= 1.0.1)
36
+ rack-test (>= 0.6.3)
37
+ rails-dom-testing (~> 2.2)
38
+ rails-html-sanitizer (~> 1.6)
39
+ useragent (~> 0.16)
40
+ actiontext (8.0.4)
41
+ actionpack (= 8.0.4)
42
+ activerecord (= 8.0.4)
43
+ activestorage (= 8.0.4)
44
+ activesupport (= 8.0.4)
45
+ globalid (>= 0.6.0)
46
+ nokogiri (>= 1.8.5)
47
+ actionview (8.0.4)
48
+ activesupport (= 8.0.4)
49
+ builder (~> 3.1)
50
+ erubi (~> 1.11)
51
+ rails-dom-testing (~> 2.2)
52
+ rails-html-sanitizer (~> 1.6)
53
+ activejob (8.0.4)
54
+ activesupport (= 8.0.4)
55
+ globalid (>= 0.3.6)
56
+ activemodel (8.0.4)
57
+ activesupport (= 8.0.4)
58
+ activerecord (8.0.4)
59
+ activemodel (= 8.0.4)
60
+ activesupport (= 8.0.4)
61
+ timeout (>= 0.4.0)
62
+ activestorage (8.0.4)
63
+ actionpack (= 8.0.4)
64
+ activejob (= 8.0.4)
65
+ activerecord (= 8.0.4)
66
+ activesupport (= 8.0.4)
67
+ marcel (~> 1.0)
68
+ activesupport (8.0.4)
69
+ base64
70
+ benchmark (>= 0.3)
71
+ bigdecimal
72
+ concurrent-ruby (~> 1.0, >= 1.3.1)
73
+ connection_pool (>= 2.2.5)
74
+ drb
75
+ i18n (>= 1.6, < 2)
76
+ logger (>= 1.4.2)
77
+ minitest (>= 5.1)
78
+ securerandom (>= 0.3)
79
+ tzinfo (~> 2.0, >= 2.0.5)
80
+ uri (>= 0.13.1)
81
+ addressable (2.8.8)
82
+ public_suffix (>= 2.0.2, < 8.0)
83
+ ast (2.4.3)
84
+ base64 (0.3.0)
85
+ benchmark (0.5.0)
86
+ bigdecimal (4.0.1)
87
+ builder (3.3.0)
88
+ capybara (3.40.0)
89
+ addressable
90
+ matrix
91
+ mini_mime (>= 0.1.3)
92
+ nokogiri (~> 1.11)
93
+ rack (>= 1.6.0)
94
+ rack-test (>= 0.6.3)
95
+ regexp_parser (>= 1.5, < 3.0)
96
+ xpath (~> 3.2)
97
+ concurrent-ruby (1.3.6)
98
+ connection_pool (3.0.2)
99
+ crass (1.0.6)
100
+ date (3.5.1)
101
+ debug (1.11.1)
102
+ irb (~> 1.10)
103
+ reline (>= 0.3.8)
104
+ diff-lcs (1.6.2)
105
+ drb (2.2.3)
106
+ erb (6.0.1)
107
+ erubi (1.13.1)
108
+ globalid (1.3.0)
109
+ activesupport (>= 6.1)
110
+ i18n (1.14.8)
111
+ concurrent-ruby (~> 1.0)
112
+ io-console (0.8.2)
113
+ irb (1.16.0)
114
+ pp (>= 0.6.0)
115
+ rdoc (>= 4.0.0)
116
+ reline (>= 0.4.2)
117
+ json (2.18.1)
118
+ language_server-protocol (3.17.0.5)
119
+ lint_roller (1.1.0)
120
+ logger (1.7.0)
121
+ loofah (2.25.0)
122
+ crass (~> 1.0.2)
123
+ nokogiri (>= 1.12.0)
124
+ mail (2.9.0)
125
+ logger
126
+ mini_mime (>= 0.1.1)
127
+ net-imap
128
+ net-pop
129
+ net-smtp
130
+ marcel (1.1.0)
131
+ matrix (0.4.3)
132
+ mini_mime (1.1.5)
133
+ minitest (6.0.1)
134
+ prism (~> 1.5)
135
+ net-imap (0.6.2)
136
+ date
137
+ net-protocol
138
+ net-pop (0.1.2)
139
+ net-protocol
140
+ net-protocol (0.2.2)
141
+ timeout
142
+ net-smtp (0.5.1)
143
+ net-protocol
144
+ nio4r (2.7.5)
145
+ nokogiri (1.19.0-aarch64-linux-gnu)
146
+ racc (~> 1.4)
147
+ nokogiri (1.19.0-aarch64-linux-musl)
148
+ racc (~> 1.4)
149
+ nokogiri (1.19.0-arm-linux-gnu)
150
+ racc (~> 1.4)
151
+ nokogiri (1.19.0-arm-linux-musl)
152
+ racc (~> 1.4)
153
+ nokogiri (1.19.0-arm64-darwin)
154
+ racc (~> 1.4)
155
+ nokogiri (1.19.0-x86_64-darwin)
156
+ racc (~> 1.4)
157
+ nokogiri (1.19.0-x86_64-linux-gnu)
158
+ racc (~> 1.4)
159
+ nokogiri (1.19.0-x86_64-linux-musl)
160
+ racc (~> 1.4)
161
+ parallel (1.27.0)
162
+ parser (3.3.10.1)
163
+ ast (~> 2.4.1)
164
+ racc
165
+ pp (0.6.3)
166
+ prettyprint
167
+ prettyprint (0.2.0)
168
+ prism (1.9.0)
169
+ psych (5.3.1)
170
+ date
171
+ stringio
172
+ public_suffix (7.0.2)
173
+ puma (6.6.1)
174
+ nio4r (~> 2.0)
175
+ racc (1.8.1)
176
+ rack (3.2.4)
177
+ rack-session (2.1.1)
178
+ base64 (>= 0.1.0)
179
+ rack (>= 3.0.0)
180
+ rack-test (2.2.0)
181
+ rack (>= 1.3)
182
+ rackup (2.3.1)
183
+ rack (>= 3)
184
+ rails (8.0.4)
185
+ actioncable (= 8.0.4)
186
+ actionmailbox (= 8.0.4)
187
+ actionmailer (= 8.0.4)
188
+ actionpack (= 8.0.4)
189
+ actiontext (= 8.0.4)
190
+ actionview (= 8.0.4)
191
+ activejob (= 8.0.4)
192
+ activemodel (= 8.0.4)
193
+ activerecord (= 8.0.4)
194
+ activestorage (= 8.0.4)
195
+ activesupport (= 8.0.4)
196
+ bundler (>= 1.15.0)
197
+ railties (= 8.0.4)
198
+ rails-dom-testing (2.3.0)
199
+ activesupport (>= 5.0.0)
200
+ minitest
201
+ nokogiri (>= 1.6)
202
+ rails-html-sanitizer (1.6.2)
203
+ loofah (~> 2.21)
204
+ nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
205
+ railties (8.0.4)
206
+ actionpack (= 8.0.4)
207
+ activesupport (= 8.0.4)
208
+ irb (~> 1.13)
209
+ rackup (>= 1.0.0)
210
+ rake (>= 12.2)
211
+ thor (~> 1.0, >= 1.2.2)
212
+ tsort (>= 0.2)
213
+ zeitwerk (~> 2.6)
214
+ rainbow (3.1.1)
215
+ rake (13.3.1)
216
+ rdoc (7.1.0)
217
+ erb
218
+ psych (>= 4.0.0)
219
+ tsort
220
+ regexp_parser (2.11.3)
221
+ reline (0.6.3)
222
+ io-console (~> 0.5)
223
+ rexml (3.4.4)
224
+ rspec-core (3.13.6)
225
+ rspec-support (~> 3.13.0)
226
+ rspec-expectations (3.13.5)
227
+ diff-lcs (>= 1.2.0, < 2.0)
228
+ rspec-support (~> 3.13.0)
229
+ rspec-mocks (3.13.7)
230
+ diff-lcs (>= 1.2.0, < 2.0)
231
+ rspec-support (~> 3.13.0)
232
+ rspec-rails (6.1.5)
233
+ actionpack (>= 6.1)
234
+ activesupport (>= 6.1)
235
+ railties (>= 6.1)
236
+ rspec-core (~> 3.13)
237
+ rspec-expectations (~> 3.13)
238
+ rspec-mocks (~> 3.13)
239
+ rspec-support (~> 3.13)
240
+ rspec-support (3.13.7)
241
+ rubocop (1.84.1)
242
+ json (~> 2.3)
243
+ language_server-protocol (~> 3.17.0.2)
244
+ lint_roller (~> 1.1.0)
245
+ parallel (~> 1.10)
246
+ parser (>= 3.3.0.2)
247
+ rainbow (>= 2.2.2, < 4.0)
248
+ regexp_parser (>= 2.9.3, < 3.0)
249
+ rubocop-ast (>= 1.49.0, < 2.0)
250
+ ruby-progressbar (~> 1.7)
251
+ unicode-display_width (>= 2.4.0, < 4.0)
252
+ rubocop-ast (1.49.0)
253
+ parser (>= 3.3.7.2)
254
+ prism (~> 1.7)
255
+ rubocop-rails (2.34.3)
256
+ activesupport (>= 4.2.0)
257
+ lint_roller (~> 1.1)
258
+ rack (>= 1.1)
259
+ rubocop (>= 1.75.0, < 2.0)
260
+ rubocop-ast (>= 1.44.0, < 2.0)
261
+ rubocop-rspec (3.9.0)
262
+ lint_roller (~> 1.1)
263
+ rubocop (~> 1.81)
264
+ ruby-progressbar (1.13.0)
265
+ rubyzip (3.2.2)
266
+ securerandom (0.4.1)
267
+ selenium-webdriver (4.40.0)
268
+ base64 (~> 0.2)
269
+ logger (~> 1.4)
270
+ rexml (~> 3.2, >= 3.2.5)
271
+ rubyzip (>= 1.2.2, < 4.0)
272
+ websocket (~> 1.0)
273
+ sqlite3 (2.9.0-aarch64-linux-gnu)
274
+ sqlite3 (2.9.0-aarch64-linux-musl)
275
+ sqlite3 (2.9.0-arm-linux-gnu)
276
+ sqlite3 (2.9.0-arm-linux-musl)
277
+ sqlite3 (2.9.0-arm64-darwin)
278
+ sqlite3 (2.9.0-x86_64-darwin)
279
+ sqlite3 (2.9.0-x86_64-linux-gnu)
280
+ sqlite3 (2.9.0-x86_64-linux-musl)
281
+ stringio (3.2.0)
282
+ thor (1.5.0)
283
+ timeout (0.6.0)
284
+ tsort (0.2.0)
285
+ tzinfo (2.0.6)
286
+ concurrent-ruby (~> 1.0)
287
+ unicode-display_width (3.2.0)
288
+ unicode-emoji (~> 4.1)
289
+ unicode-emoji (4.2.0)
290
+ uri (1.1.1)
291
+ useragent (0.16.11)
292
+ websocket (1.2.11)
293
+ websocket-driver (0.8.0)
294
+ base64
295
+ websocket-extensions (>= 0.1.0)
296
+ websocket-extensions (0.1.5)
297
+ xpath (3.2.0)
298
+ nokogiri (~> 1.8)
299
+ zeitwerk (2.7.4)
300
+
301
+ PLATFORMS
302
+ aarch64-linux-gnu
303
+ aarch64-linux-musl
304
+ arm-linux-gnu
305
+ arm-linux-musl
306
+ arm64-darwin
307
+ x86_64-darwin
308
+ x86_64-linux-gnu
309
+ x86_64-linux-musl
310
+
311
+ DEPENDENCIES
312
+ capybara (~> 3.39)
313
+ debug
314
+ hotwire_nested_form!
315
+ puma (~> 6.0)
316
+ rails (~> 8.0.0)
317
+ rspec-rails (~> 6.0)
318
+ rubocop (~> 1.50)
319
+ rubocop-rails
320
+ rubocop-rspec
321
+ selenium-webdriver (~> 4.10)
322
+ sqlite3 (>= 1.6)
323
+
324
+ BUNDLED WITH
325
+ 2.5.16
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 BhumitBhadani
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,252 @@
1
+ # HotwireNestedForm
2
+
3
+ [![Gem Version](https://badge.fury.io/rb/hotwire_nested_form.svg)](https://badge.fury.io/rb/hotwire_nested_form)
4
+ [![CI](https://github.com/bhumit4220/hotwire_nested_form/actions/workflows/test.yml/badge.svg)](https://github.com/bhumit4220/hotwire_nested_form/actions)
5
+
6
+ A modern, Stimulus-based gem for dynamic nested forms in Rails. Drop-in replacement for Cocoon with zero jQuery dependency and full Turbo compatibility.
7
+
8
+ ## Why HotwireNestedForm?
9
+
10
+ | Feature | Cocoon | HotwireNestedForm |
11
+ |---------|--------|-------------------|
12
+ | jQuery required | Yes | No |
13
+ | Turbo compatible | No | Yes |
14
+ | `preventDefault()` works | No | Yes |
15
+ | Maintained | No (since 2020) | Yes |
16
+ | Rails 7+ support | Partial | Full |
17
+
18
+ ## Installation
19
+
20
+ Add to your Gemfile:
21
+
22
+ ```ruby
23
+ gem "hotwire_nested_form"
24
+ ```
25
+
26
+ Run the installer:
27
+
28
+ ```bash
29
+ bundle install
30
+ rails generate hotwire_nested_form:install
31
+ ```
32
+
33
+ ## Quick Start
34
+
35
+ ### 1. Model Setup
36
+
37
+ ```ruby
38
+ # app/models/project.rb
39
+ class Project < ApplicationRecord
40
+ has_many :tasks, dependent: :destroy
41
+ accepts_nested_attributes_for :tasks, allow_destroy: true, reject_if: :all_blank
42
+ end
43
+ ```
44
+
45
+ ### 2. Controller Setup
46
+
47
+ ```ruby
48
+ # app/controllers/projects_controller.rb
49
+ def project_params
50
+ params.require(:project).permit(:name, tasks_attributes: [:id, :name, :_destroy])
51
+ end
52
+ ```
53
+
54
+ ### 3. Form Setup
55
+
56
+ ```erb
57
+ <%# app/views/projects/_form.html.erb %>
58
+ <%= form_with model: @project do |f| %>
59
+ <%= f.text_field :name %>
60
+
61
+ <div data-controller="nested-form">
62
+ <div id="tasks">
63
+ <%= f.fields_for :tasks do |task_form| %>
64
+ <%= render "task_fields", f: task_form %>
65
+ <% end %>
66
+ </div>
67
+
68
+ <%= link_to_add_association "Add Task", f, :tasks %>
69
+ </div>
70
+
71
+ <%= f.submit %>
72
+ <% end %>
73
+ ```
74
+
75
+ ### 4. Fields Partial
76
+
77
+ ```erb
78
+ <%# app/views/projects/_task_fields.html.erb %>
79
+ <div class="nested-fields">
80
+ <%= f.text_field :name, placeholder: "Task name" %>
81
+ <%= link_to_remove_association "Remove", f %>
82
+ </div>
83
+ ```
84
+
85
+ That's it! Click "Add Task" to add fields, "Remove" to remove them.
86
+
87
+ ## API Reference
88
+
89
+ ### link_to_add_association
90
+
91
+ ```ruby
92
+ link_to_add_association(name, form, association, options = {}, &block)
93
+ ```
94
+
95
+ | Option | Type | Default | Description |
96
+ |--------|------|---------|-------------|
97
+ | `:partial` | String | `"#{assoc}_fields"` | Custom partial path |
98
+ | `:count` | Integer | `1` | Fields to add per click |
99
+ | `:insertion` | Symbol | `:before` | `:before`, `:after`, `:append`, `:prepend` |
100
+ | `:target` | String | `nil` | CSS selector for insertion target |
101
+ | `:wrap_object` | Proc | `nil` | Wrap new object (for decorators) |
102
+ | `:render_options` | Hash | `{}` | Options passed to `render` |
103
+
104
+ **Examples:**
105
+
106
+ ```erb
107
+ <%# Basic usage %>
108
+ <%= link_to_add_association "Add Task", f, :tasks %>
109
+
110
+ <%# With custom partial %>
111
+ <%= link_to_add_association "Add Task", f, :tasks,
112
+ partial: "projects/custom_task_fields" %>
113
+
114
+ <%# With block for custom content %>
115
+ <%= link_to_add_association f, :tasks do %>
116
+ <span class="icon">+</span> Add Task
117
+ <% end %>
118
+
119
+ <%# With HTML classes %>
120
+ <%= link_to_add_association "Add Task", f, :tasks,
121
+ class: "btn btn-primary" %>
122
+
123
+ <%# Add multiple at once %>
124
+ <%= link_to_add_association "Add 3 Tasks", f, :tasks, count: 3 %>
125
+ ```
126
+
127
+ ### link_to_remove_association
128
+
129
+ ```ruby
130
+ link_to_remove_association(name, form, options = {}, &block)
131
+ ```
132
+
133
+ | Option | Type | Default | Description |
134
+ |--------|------|---------|-------------|
135
+ | `:wrapper_class` | String | `"nested-fields"` | Class of wrapper to remove |
136
+
137
+ **Examples:**
138
+
139
+ ```erb
140
+ <%# Basic usage %>
141
+ <%= link_to_remove_association "Remove", f %>
142
+
143
+ <%# With custom wrapper class %>
144
+ <%= link_to_remove_association "Remove", f,
145
+ wrapper_class: "task-item" %>
146
+
147
+ <%# With block %>
148
+ <%= link_to_remove_association f do %>
149
+ <span class="icon">&times;</span> Remove
150
+ <% end %>
151
+ ```
152
+
153
+ ## JavaScript Events
154
+
155
+ | Event | Cancelable | Detail | When |
156
+ |-------|------------|--------|------|
157
+ | `nested-form:before-add` | Yes | `{ wrapper }` | Before adding fields |
158
+ | `nested-form:after-add` | No | `{ wrapper }` | After fields added |
159
+ | `nested-form:before-remove` | Yes | `{ wrapper }` | Before removing fields |
160
+ | `nested-form:after-remove` | No | `{ wrapper }` | After fields removed |
161
+
162
+ **Usage Examples:**
163
+
164
+ ```javascript
165
+ // Prevent adding if limit reached
166
+ document.addEventListener("nested-form:before-add", (event) => {
167
+ const taskCount = document.querySelectorAll(".nested-fields").length
168
+ if (taskCount >= 10) {
169
+ event.preventDefault()
170
+ alert("Maximum 10 tasks allowed")
171
+ }
172
+ })
173
+
174
+ // Initialize plugins on new fields
175
+ document.addEventListener("nested-form:after-add", (event) => {
176
+ const wrapper = event.detail.wrapper
177
+ // Initialize datepicker, select2, etc.
178
+ })
179
+
180
+ // Confirm before removing
181
+ document.addEventListener("nested-form:before-remove", (event) => {
182
+ if (!confirm("Are you sure?")) {
183
+ event.preventDefault()
184
+ }
185
+ })
186
+
187
+ // Update totals after removal
188
+ document.addEventListener("nested-form:after-remove", (event) => {
189
+ updateTaskCount()
190
+ })
191
+ ```
192
+
193
+ ## Migrating from Cocoon
194
+
195
+ 1. Replace gem in Gemfile:
196
+ ```ruby
197
+ # Remove: gem "cocoon"
198
+ gem "hotwire_nested_form"
199
+ ```
200
+
201
+ 2. Run installer:
202
+ ```bash
203
+ bundle install
204
+ rails generate hotwire_nested_form:install
205
+ ```
206
+
207
+ 3. Add `data-controller="nested-form"` to your form wrapper:
208
+ ```erb
209
+ <div data-controller="nested-form">
210
+ <!-- your fields_for and links here -->
211
+ </div>
212
+ ```
213
+
214
+ 4. Update event listeners (optional):
215
+ ```javascript
216
+ // Before: cocoon:before-insert
217
+ // After: nested-form:before-add
218
+
219
+ // Before: cocoon:after-insert
220
+ // After: nested-form:after-add
221
+
222
+ // Before: cocoon:before-remove
223
+ // After: nested-form:before-remove
224
+
225
+ // Before: cocoon:after-remove
226
+ // After: nested-form:after-remove
227
+ ```
228
+
229
+ 5. Remove jQuery if no longer needed.
230
+
231
+ ## Requirements
232
+
233
+ - Ruby 3.1+
234
+ - Rails 7.0+ (including Rails 8)
235
+ - Stimulus (included in Rails 7+ by default)
236
+
237
+ ## Development
238
+
239
+ After checking out the repo, run:
240
+
241
+ ```bash
242
+ bundle install
243
+ bundle exec rspec
244
+ ```
245
+
246
+ ## Contributing
247
+
248
+ Bug reports and pull requests are welcome on GitHub at https://github.com/bhumit4220/hotwire_nested_form.
249
+
250
+ ## License
251
+
252
+ MIT License. See [LICENSE](LICENSE) for details.
data/Rakefile ADDED
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bundler/gem_tasks'
4
+ require 'rspec/core/rake_task'
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ task default: :spec
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ source 'https://rubygems.org'
4
+
5
+ gem 'rails', '~> 7.0.0'
6
+
7
+ gemspec path: '../'
8
+
9
+ group :development, :test do
10
+ gem 'capybara', '~> 3.39'
11
+ gem 'debug', require: false
12
+ gem 'puma', '~> 6.0'
13
+ gem 'rspec-rails', '~> 6.0'
14
+ gem 'rubocop', '~> 1.50', require: false
15
+ gem 'rubocop-rails', require: false
16
+ gem 'rubocop-rspec', require: false
17
+ gem 'selenium-webdriver', '~> 4.10'
18
+ gem 'sqlite3', '~> 1.6'
19
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ source 'https://rubygems.org'
4
+
5
+ gem 'rails', '~> 7.1.0'
6
+
7
+ gemspec path: '../'
8
+
9
+ group :development, :test do
10
+ gem 'capybara', '~> 3.39'
11
+ gem 'debug', require: false
12
+ gem 'puma', '~> 6.0'
13
+ gem 'rspec-rails', '~> 6.0'
14
+ gem 'rubocop', '~> 1.50', require: false
15
+ gem 'rubocop-rails', require: false
16
+ gem 'rubocop-rspec', require: false
17
+ gem 'selenium-webdriver', '~> 4.10'
18
+ gem 'sqlite3', '~> 1.6'
19
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ source 'https://rubygems.org'
4
+
5
+ gem 'rails', '~> 8.0.0'
6
+
7
+ gemspec path: '../'
8
+
9
+ group :development, :test do
10
+ gem 'capybara', '~> 3.39'
11
+ gem 'debug', require: false
12
+ gem 'puma', '~> 6.0'
13
+ gem 'rspec-rails', '~> 6.0'
14
+ gem 'rubocop', '~> 1.50', require: false
15
+ gem 'rubocop-rails', require: false
16
+ gem 'rubocop-rspec', require: false
17
+ gem 'selenium-webdriver', '~> 4.10'
18
+ gem 'sqlite3', '~> 2.0'
19
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HotwireNestedForm
4
+ module Generators
5
+ class InstallGenerator < Rails::Generators::Base
6
+ source_root File.expand_path('templates', __dir__)
7
+
8
+ desc 'Install hotwire_nested_form'
9
+
10
+ def copy_stimulus_controller
11
+ copy_file 'nested_form_controller.js',
12
+ 'app/javascript/controllers/nested_form_controller.js'
13
+ end
14
+
15
+ def show_post_install_message
16
+ say ''
17
+ say '=' * 60
18
+ say 'hotwire_nested_form installed successfully!', :green
19
+ say '=' * 60
20
+ say ''
21
+ say 'Next steps:', :yellow
22
+ say ''
23
+ say '1. Add accepts_nested_attributes_for to your model:'
24
+ say ''
25
+ say ' class Project < ApplicationRecord'
26
+ say ' has_many :tasks, dependent: :destroy'
27
+ say ' accepts_nested_attributes_for :tasks, allow_destroy: true'
28
+ say ' end'
29
+ say ''
30
+ say '2. Wrap your form in data-controller="nested-form":'
31
+ say ''
32
+ say ' <%= form_with model: @project do |f| %>'
33
+ say ' <div data-controller="nested-form">'
34
+ say ' <div id="tasks">'
35
+ say ' <%= f.fields_for :tasks do |tf| %>'
36
+ say " <%= render 'task_fields', f: tf %>"
37
+ say ' <% end %>'
38
+ say ' </div>'
39
+ say " <%= link_to_add_association 'Add Task', f, :tasks %>"
40
+ say ' </div>'
41
+ say ' <% end %>'
42
+ say ''
43
+ say '3. Create a partial for your nested fields:'
44
+ say ''
45
+ say ' <%# _task_fields.html.erb %>'
46
+ say ' <div class="nested-fields">'
47
+ say ' <%= f.text_field :name %>'
48
+ say " <%= link_to_remove_association 'Remove', f %>"
49
+ say ' </div>'
50
+ say ''
51
+ say '4. Permit nested attributes in your controller:'
52
+ say ''
53
+ say ' def project_params'
54
+ say ' params.require(:project).permit(:name,'
55
+ say ' tasks_attributes: [:id, :name, :_destroy])'
56
+ say ' end'
57
+ say ''
58
+ say '=' * 60
59
+ say ''
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,87 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // Connects to data-controller="nested-form"
4
+ export default class extends Controller {
5
+ static values = {
6
+ wrapperClass: { type: String, default: "nested-fields" }
7
+ }
8
+
9
+ add(event) {
10
+ event.preventDefault()
11
+
12
+ const template = event.currentTarget.dataset.template
13
+ const insertion = event.currentTarget.dataset.insertion || "before"
14
+ const targetSelector = event.currentTarget.dataset.target
15
+ const count = parseInt(event.currentTarget.dataset.count) || 1
16
+
17
+ for (let i = 0; i < count; i++) {
18
+ this.#insertFields(template, insertion, targetSelector, event.currentTarget)
19
+ }
20
+ }
21
+
22
+ remove(event) {
23
+ event.preventDefault()
24
+
25
+ const wrapper = event.currentTarget.closest(`.${this.wrapperClassValue}`)
26
+ if (!wrapper) return
27
+
28
+ // Dispatch cancelable before-remove event
29
+ const beforeEvent = this.dispatch("before-remove", {
30
+ cancelable: true,
31
+ detail: { wrapper }
32
+ })
33
+
34
+ if (beforeEvent.defaultPrevented) return
35
+
36
+ const destroyInput = wrapper.querySelector("input[name*='_destroy']")
37
+
38
+ if (destroyInput) {
39
+ // Persisted record - hide and mark for destruction
40
+ destroyInput.value = "true"
41
+ wrapper.style.display = "none"
42
+ } else {
43
+ // New record - remove from DOM
44
+ wrapper.remove()
45
+ }
46
+
47
+ this.dispatch("after-remove", { detail: { wrapper } })
48
+ }
49
+
50
+ // Private
51
+
52
+ #insertFields(template, insertion, targetSelector, trigger) {
53
+ const newId = new Date().getTime()
54
+ const content = template.replace(/NEW_RECORD/g, newId)
55
+
56
+ const fragment = document.createRange().createContextualFragment(content)
57
+ const wrapper = fragment.firstElementChild
58
+
59
+ // Dispatch cancelable before-add event
60
+ const beforeEvent = this.dispatch("before-add", {
61
+ cancelable: true,
62
+ detail: { wrapper }
63
+ })
64
+
65
+ if (beforeEvent.defaultPrevented) return
66
+
67
+ const container = targetSelector
68
+ ? document.querySelector(targetSelector)
69
+ : trigger.parentElement
70
+
71
+ switch (insertion) {
72
+ case "after":
73
+ trigger.after(fragment)
74
+ break
75
+ case "append":
76
+ container.append(fragment)
77
+ break
78
+ case "prepend":
79
+ container.prepend(fragment)
80
+ break
81
+ default: // "before"
82
+ trigger.before(fragment)
83
+ }
84
+
85
+ this.dispatch("after-add", { detail: { wrapper } })
86
+ }
87
+ }
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HotwireNestedForm
4
+ class Engine < ::Rails::Engine
5
+ isolate_namespace HotwireNestedForm
6
+
7
+ # Auto-include helpers in all views
8
+ initializer 'hotwire_nested_form.helpers' do
9
+ ActiveSupport.on_load(:action_view) do
10
+ include HotwireNestedForm::Helpers
11
+ end
12
+ end
13
+
14
+ # Support for asset pipeline (fallback)
15
+ initializer 'hotwire_nested_form.assets' do |app|
16
+ app.config.assets.paths << Engine.root.join('app/assets/javascripts') if app.config.respond_to?(:assets)
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HotwireNestedForm
4
+ module Helpers
5
+ module AddAssociation
6
+ # Generates a link to add nested form fields
7
+ #
8
+ # @param name [String] Link text (or use block)
9
+ # @param form [FormBuilder] Parent form object
10
+ # @param association [Symbol] Association name
11
+ # @param options [Hash] HTML attributes and gem options
12
+ # @yield Block for custom link content
13
+ # @return [String] HTML link element
14
+ #
15
+ # @example Basic usage
16
+ # <%= link_to_add_association "Add Task", f, :tasks %>
17
+ #
18
+ # @example With block
19
+ # <%= link_to_add_association f, :tasks do %>
20
+ # <span>+ Add Task</span>
21
+ # <% end %>
22
+ #
23
+ def link_to_add_association(name = nil, form = nil, association = nil, options = {}, &)
24
+ # Handle block syntax: link_to_add_association(form, :tasks) { "Add" }
25
+ if block_given?
26
+ options = association || {}
27
+ association = form
28
+ form = name
29
+ name = capture(&)
30
+ end
31
+
32
+ raise ArgumentError, 'form is required' unless form
33
+ raise ArgumentError, 'association is required' unless association
34
+
35
+ options = options.dup
36
+
37
+ # Extract gem-specific options
38
+ partial = options.delete(:partial)
39
+ render_options = options.delete(:render_options) || {}
40
+ wrap_object = options.delete(:wrap_object)
41
+ count = options.delete(:count) || 1
42
+ insertion = options.delete(:insertion) || :before
43
+ target = options.delete(:target)
44
+
45
+ # Build the template
46
+ template = build_association_template(
47
+ form,
48
+ association,
49
+ partial: partial,
50
+ render_options: render_options,
51
+ wrap_object: wrap_object
52
+ )
53
+
54
+ # Build data attributes
55
+ data = options[:data] || {}
56
+ data[:action] = 'nested-form#add'
57
+ data[:template] = template
58
+ data[:insertion] = insertion
59
+ data[:count] = count if count > 1
60
+ data[:target] = target if target
61
+
62
+ options[:data] = data
63
+ options[:href] = '#'
64
+
65
+ content_tag(:a, name, options)
66
+ end
67
+
68
+ private
69
+
70
+ def build_association_template(form, association, partial:, render_options:, wrap_object:)
71
+ # Get the association reflection
72
+ reflection = form.object.class.reflect_on_association(association)
73
+ raise ArgumentError, "Association #{association} not found" unless reflection
74
+
75
+ # Build a new object for the association
76
+ new_object = build_association_object(form.object, reflection, wrap_object)
77
+
78
+ # Determine partial name
79
+ partial_name = partial || "#{association.to_s.singularize}_fields"
80
+
81
+ # Render the fields
82
+ form.fields_for(association, new_object, child_index: 'NEW_RECORD') do |builder|
83
+ locals = (render_options[:locals] || {}).merge(f: builder)
84
+ render(partial: partial_name, locals: locals)
85
+ end
86
+ end
87
+
88
+ def build_association_object(parent, reflection, wrap_object)
89
+ new_object = case reflection.macro
90
+ when :has_many, :has_and_belongs_to_many
91
+ reflection.klass.new
92
+ when :has_one
93
+ parent.send("build_#{reflection.name}")
94
+ else
95
+ reflection.klass.new
96
+ end
97
+
98
+ wrap_object ? wrap_object.call(new_object) : new_object
99
+ end
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HotwireNestedForm
4
+ module Helpers
5
+ module RemoveAssociation
6
+ # Generates a link to remove nested form fields
7
+ #
8
+ # @param name [String] Link text (or use block)
9
+ # @param form [FormBuilder] Nested form object
10
+ # @param options [Hash] HTML attributes and gem options
11
+ # @yield Block for custom link content
12
+ # @return [String] HTML link element + hidden _destroy field
13
+ #
14
+ # @example Basic usage
15
+ # <%= link_to_remove_association "Remove", f %>
16
+ #
17
+ # @example With block
18
+ # <%= link_to_remove_association f do %>
19
+ # <span class="icon">×</span>
20
+ # <% end %>
21
+ #
22
+ def link_to_remove_association(name = nil, form = nil, options = {}, &)
23
+ # Handle block syntax: link_to_remove_association(form) { "Remove" }
24
+ if block_given?
25
+ options = form || {}
26
+ form = name
27
+ name = capture(&)
28
+ end
29
+
30
+ raise ArgumentError, 'form is required' unless form
31
+
32
+ options = options.dup
33
+
34
+ # Build data attributes
35
+ data = options[:data] || {}
36
+ data[:action] = 'nested-form#remove'
37
+
38
+ options[:data] = data
39
+ options[:href] = '#'
40
+
41
+ # Build the hidden _destroy field for persisted records
42
+ hidden_field = if form.object&.persisted?
43
+ form.hidden_field(:_destroy, value: false)
44
+ else
45
+ ''.html_safe
46
+ end
47
+
48
+ safe_join([hidden_field, content_tag(:a, name, options)])
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'helpers/add_association'
4
+ require_relative 'helpers/remove_association'
5
+
6
+ module HotwireNestedForm
7
+ module Helpers
8
+ include AddAssociation
9
+ include RemoveAssociation
10
+ end
11
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HotwireNestedForm
4
+ VERSION = '1.0.0'
5
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'hotwire_nested_form/version'
4
+ require_relative 'hotwire_nested_form/engine'
5
+ require_relative 'hotwire_nested_form/helpers'
6
+
7
+ module HotwireNestedForm
8
+ class Error < StandardError; end
9
+ end
metadata ADDED
@@ -0,0 +1,83 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: hotwire_nested_form
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - BhumitBhadani
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-02-05 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: rails
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '7.0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '7.0'
27
+ description: A modern, Stimulus-based replacement for Cocoon. Dynamically add and
28
+ remove nested form fields with full Turbo compatibility and zero jQuery dependency.
29
+ email:
30
+ - bhumit2520@gmail.com
31
+ executables: []
32
+ extensions: []
33
+ extra_rdoc_files: []
34
+ files:
35
+ - ".rubocop.yml"
36
+ - ".ruby-version"
37
+ - CHANGELOG.md
38
+ - Gemfile
39
+ - Gemfile.lock
40
+ - LICENSE
41
+ - README.md
42
+ - Rakefile
43
+ - gemfiles/rails_7.0.gemfile
44
+ - gemfiles/rails_7.1.gemfile
45
+ - gemfiles/rails_8.0.gemfile
46
+ - lib/generators/hotwire_nested_form/install_generator.rb
47
+ - lib/generators/hotwire_nested_form/templates/nested_form_controller.js
48
+ - lib/hotwire_nested_form.rb
49
+ - lib/hotwire_nested_form/engine.rb
50
+ - lib/hotwire_nested_form/helpers.rb
51
+ - lib/hotwire_nested_form/helpers/add_association.rb
52
+ - lib/hotwire_nested_form/helpers/remove_association.rb
53
+ - lib/hotwire_nested_form/version.rb
54
+ homepage: https://github.com/bhumit4220/hotwire_nested_form
55
+ licenses:
56
+ - MIT
57
+ metadata:
58
+ homepage_uri: https://github.com/bhumit4220/hotwire_nested_form
59
+ source_code_uri: https://github.com/bhumit4220/hotwire_nested_form
60
+ changelog_uri: https://github.com/bhumit4220/hotwire_nested_form/blob/main/CHANGELOG.md
61
+ bug_tracker_uri: https://github.com/bhumit4220/hotwire_nested_form/issues
62
+ documentation_uri: https://github.com/bhumit4220/hotwire_nested_form#readme
63
+ rubygems_mfa_required: 'true'
64
+ post_install_message:
65
+ rdoc_options: []
66
+ require_paths:
67
+ - lib
68
+ required_ruby_version: !ruby/object:Gem::Requirement
69
+ requirements:
70
+ - - ">="
71
+ - !ruby/object:Gem::Version
72
+ version: 3.1.0
73
+ required_rubygems_version: !ruby/object:Gem::Requirement
74
+ requirements:
75
+ - - ">="
76
+ - !ruby/object:Gem::Version
77
+ version: '0'
78
+ requirements: []
79
+ rubygems_version: 3.5.16
80
+ signing_key:
81
+ specification_version: 4
82
+ summary: Dynamic nested forms for Rails with Stimulus
83
+ test_files: []