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 +7 -0
- data/.rubocop.yml +113 -0
- data/.ruby-version +1 -0
- data/CHANGELOG.md +26 -0
- data/Gemfile +26 -0
- data/Gemfile.lock +325 -0
- data/LICENSE +21 -0
- data/README.md +252 -0
- data/Rakefile +8 -0
- data/gemfiles/rails_7.0.gemfile +19 -0
- data/gemfiles/rails_7.1.gemfile +19 -0
- data/gemfiles/rails_8.0.gemfile +19 -0
- data/lib/generators/hotwire_nested_form/install_generator.rb +63 -0
- data/lib/generators/hotwire_nested_form/templates/nested_form_controller.js +87 -0
- data/lib/hotwire_nested_form/engine.rb +19 -0
- data/lib/hotwire_nested_form/helpers/add_association.rb +102 -0
- data/lib/hotwire_nested_form/helpers/remove_association.rb +52 -0
- data/lib/hotwire_nested_form/helpers.rb +11 -0
- data/lib/hotwire_nested_form/version.rb +5 -0
- data/lib/hotwire_nested_form.rb +9 -0
- metadata +83 -0
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
|
+
[](https://badge.fury.io/rb/hotwire_nested_form)
|
|
4
|
+
[](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">×</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,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
|
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: []
|