hotwire_nested_form 1.1.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 614ce7e4bb9ee960608d18431bd94319feac263ce6a83f79bc36525f5c2d5061
4
- data.tar.gz: 3c8c196d819cf4954fbedf3444070a95244636427bb73147d3edc71465d5afb2
3
+ metadata.gz: 95575593f362dd0db15938a6b130eb2fa7353e12b4c939c84ee4aa1eb00035ec
4
+ data.tar.gz: 8ace6c3f971faaadc42d2692a6540bd4a907387302963573a904ad7990b1a3b6
5
5
  SHA512:
6
- metadata.gz: 5ec8765adbc4c27dbd3297f6497971f29a22872381c114c12ce03c7ee8649e0e47e6c500be06c7f1ee71a3512f7897f9a94bc96f95474e730874828188784579
7
- data.tar.gz: d90976447adf75d242bdb1f200af575e3724036b7441876e48134a28f4abd7c603cc62cb9c997679a8234b19b3a6a3465e77e51fb944a144b25b0f4c4e1dc7ad
6
+ metadata.gz: 409ccb4b58b6be6caba5a0b8b5634f999337677de6c146ea3bb100bb3bab9496dca69f4ae5c72ed2587419d321552bb4c7bfb09788ac7664069c0fd2e5ae7e0d
7
+ data.tar.gz: 4b5902764ddc0e1e7b3223cd6f449a325089198a4a0fadee4077638eb7b403bc0179f0105233950c63b73f828339c6b796f577e3d807e67ec66137e286784791
data/CHANGELOG.md CHANGED
@@ -7,11 +7,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.2.0] - 2026-02-05
11
+
12
+ ### Added
13
+ - Dynamic min/max limits via data attributes
14
+ - `data-nested-form-min-value` - minimum items required
15
+ - `data-nested-form-max-value` - maximum items allowed
16
+ - `data-nested-form-limit-behavior-value` - behavior at limits: "disable", "hide", or "error"
17
+ - New events: `nested-form:limit-reached` and `nested-form:minimum-reached`
18
+ - Formtastic form builder auto-detection and compatibility
19
+ - `formtastic?` and `formtastic_available?` methods in FormBuilderDetector
20
+
21
+ ### Changed
22
+ - Stimulus controller now updates button states automatically
23
+ - Buttons disable/hide based on current count vs limits
24
+
10
25
  ## [1.1.0] - 2026-02-05
11
26
 
12
27
  ### Added
13
28
  - SimpleForm auto-detection and compatibility
14
- - NPM package `@hotwire-nested-form/stimulus` for JavaScript-only users
29
+ - NPM package `hotwire-nested-form-stimulus` for JavaScript-only users
15
30
  - `FormBuilderDetector` module for form builder type detection
16
31
 
17
32
  ### Changed
data/Gemfile CHANGED
@@ -11,6 +11,7 @@ gem 'rails', "~> #{rails_version}.0"
11
11
 
12
12
  group :development, :test do
13
13
  gem 'capybara', '~> 3.39'
14
+ gem 'formtastic', '~> 5.0'
14
15
  gem 'puma', '~> 6.0'
15
16
  gem 'rspec-rails', '~> 6.0'
16
17
  gem 'selenium-webdriver', '~> 4.10'
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- hotwire_nested_form (1.1.0)
4
+ hotwire_nested_form (1.2.0)
5
5
  rails (>= 7.0)
6
6
 
7
7
  GEM
@@ -105,6 +105,8 @@ GEM
105
105
  drb (2.2.3)
106
106
  erb (6.0.1)
107
107
  erubi (1.13.1)
108
+ formtastic (5.0.0)
109
+ actionpack (>= 6.0.0)
108
110
  globalid (1.3.0)
109
111
  activesupport (>= 6.1)
110
112
  i18n (1.14.8)
@@ -314,6 +316,7 @@ PLATFORMS
314
316
  DEPENDENCIES
315
317
  capybara (~> 3.39)
316
318
  debug
319
+ formtastic (~> 5.0)
317
320
  hotwire_nested_form!
318
321
  puma (~> 6.0)
319
322
  rails (~> 8.0.0)
data/README.md CHANGED
@@ -104,19 +104,95 @@ Works automatically with SimpleForm! No configuration needed.
104
104
  <% end %>
105
105
  ```
106
106
 
107
+ ## Formtastic Support
108
+
109
+ Works automatically with Formtastic! No configuration needed.
110
+
111
+ ```erb
112
+ <%= semantic_form_for @project do |f| %>
113
+ <%= f.input :name %>
114
+
115
+ <div data-controller="nested-form">
116
+ <%= f.semantic_fields_for :tasks do |task_form| %>
117
+ <%= render "task_fields", f: task_form %>
118
+ <% end %>
119
+
120
+ <%= link_to_add_association "Add Task", f, :tasks %>
121
+ </div>
122
+
123
+ <%= f.actions %>
124
+ <% end %>
125
+ ```
126
+
127
+ ## Min/Max Limits
128
+
129
+ Control the number of nested items with data attributes:
130
+
131
+ ```erb
132
+ <div data-controller="nested-form"
133
+ data-nested-form-min-value="1"
134
+ data-nested-form-max-value="5"
135
+ data-nested-form-limit-behavior-value="disable">
136
+
137
+ <%= f.fields_for :tasks do |tf| %>
138
+ <%= render "task_fields", f: tf %>
139
+ <% end %>
140
+
141
+ <%= link_to_add_association "Add Task", f, :tasks %>
142
+ </div>
143
+ ```
144
+
145
+ ### Limit Options
146
+
147
+ | Attribute | Type | Default | Description |
148
+ |-----------|------|---------|-------------|
149
+ | `data-nested-form-min-value` | Integer | `0` | Minimum items required |
150
+ | `data-nested-form-max-value` | Integer | unlimited | Maximum items allowed |
151
+ | `data-nested-form-limit-behavior-value` | String | `"disable"` | `"disable"`, `"hide"`, or `"error"` |
152
+
153
+ ### Limit Behaviors
154
+
155
+ | Behavior | At Max Limit | At Min Limit |
156
+ |----------|--------------|--------------|
157
+ | `disable` | Add button disabled | Remove buttons disabled |
158
+ | `hide` | Add button hidden | Remove buttons hidden |
159
+ | `error` | Event fires, button enabled | Event fires, button enabled |
160
+
161
+ ### Dynamic Limits
162
+
163
+ Change limits at runtime via JavaScript:
164
+
165
+ ```javascript
166
+ const form = document.querySelector('[data-controller="nested-form"]')
167
+ form.dataset.nestedFormMaxValue = 10 // Change max
168
+ form.dataset.nestedFormMinValue = 2 // Change min
169
+ ```
170
+
171
+ ### Limit Events
172
+
173
+ ```javascript
174
+ document.addEventListener("nested-form:limit-reached", (event) => {
175
+ alert(`Maximum ${event.detail.limit} items allowed`)
176
+ })
177
+
178
+ document.addEventListener("nested-form:minimum-reached", (event) => {
179
+ alert(`Must keep at least ${event.detail.minimum} items`)
180
+ })
181
+ ```
182
+
107
183
  ## NPM Package (JavaScript-only)
108
184
 
109
185
  For non-Rails projects using Stimulus, install via npm:
110
186
 
111
187
  ```bash
112
- npm install @hotwire-nested-form/stimulus
188
+ npm install hotwire-nested-form-stimulus
113
189
  ```
114
190
 
115
191
  Register the controller:
116
192
 
117
193
  ```javascript
118
194
  import { Application } from "@hotwired/stimulus"
119
- import NestedFormController from "@hotwire-nested-form/stimulus"
195
+ import NestedFormController from "hotwire-nested-form-stimulus"
120
196
 
121
197
  const application = Application.start()
122
198
  application.register("nested-form", NestedFormController)
@@ -198,6 +274,8 @@ link_to_remove_association(name, form, options = {}, &block)
198
274
  | `nested-form:after-add` | No | `{ wrapper }` | After fields added |
199
275
  | `nested-form:before-remove` | Yes | `{ wrapper }` | Before removing fields |
200
276
  | `nested-form:after-remove` | No | `{ wrapper }` | After fields removed |
277
+ | `nested-form:limit-reached` | No | `{ limit, current }` | When max limit reached |
278
+ | `nested-form:minimum-reached` | No | `{ minimum, current }` | When min limit reached |
201
279
 
202
280
  **Usage Examples:**
203
281
 
@@ -1,31 +1,65 @@
1
1
  import { Controller } from "@hotwired/stimulus"
2
2
 
3
- // Connects to data-controller="nested-form"
4
3
  export default class extends Controller {
5
4
  static values = {
6
- wrapperClass: { type: String, default: "nested-fields" }
5
+ wrapperClass: { type: String, default: "nested-fields" },
6
+ min: { type: Number, default: 0 },
7
+ max: { type: Number, default: 999999 },
8
+ limitBehavior: { type: String, default: "disable" }
9
+ }
10
+
11
+ connect() {
12
+ this.updateButtonStates()
13
+ }
14
+
15
+ get currentCount() {
16
+ return this.element.querySelectorAll(`.${this.wrapperClassValue}:not([style*="display: none"])`).length
17
+ }
18
+
19
+ get addButtons() {
20
+ return this.element.querySelectorAll('[data-action*="nested-form#add"]')
21
+ }
22
+
23
+ get removeButtons() {
24
+ return this.element.querySelectorAll('[data-action*="nested-form#remove"]')
7
25
  }
8
26
 
9
27
  add(event) {
10
28
  event.preventDefault()
11
29
 
30
+ if (this.currentCount >= this.maxValue) {
31
+ this.dispatch("limit-reached", {
32
+ detail: { limit: this.maxValue, current: this.currentCount }
33
+ })
34
+ return
35
+ }
36
+
12
37
  const template = event.currentTarget.dataset.template
13
38
  const insertion = event.currentTarget.dataset.insertion || "before"
14
39
  const targetSelector = event.currentTarget.dataset.target
15
40
  const count = parseInt(event.currentTarget.dataset.count) || 1
16
41
 
17
42
  for (let i = 0; i < count; i++) {
18
- this.#insertFields(template, insertion, targetSelector, event.currentTarget)
43
+ if (this.currentCount >= this.maxValue) break
44
+ this.insertFields(template, insertion, targetSelector, event.currentTarget)
19
45
  }
46
+
47
+ this.updateButtonStates()
20
48
  }
21
49
 
22
50
  remove(event) {
23
51
  event.preventDefault()
24
52
 
53
+ if (this.currentCount <= this.minValue) {
54
+ this.dispatch("minimum-reached", {
55
+ detail: { minimum: this.minValue, current: this.currentCount }
56
+ })
57
+ return
58
+ }
59
+
25
60
  const wrapper = event.currentTarget.closest(`.${this.wrapperClassValue}`)
26
61
  if (!wrapper) return
27
62
 
28
- // Dispatch cancelable before-remove event
29
63
  const beforeEvent = this.dispatch("before-remove", {
30
64
  cancelable: true,
31
65
  detail: { wrapper }
@@ -36,27 +70,23 @@ export default class extends Controller {
36
70
  const destroyInput = wrapper.querySelector("input[name*='_destroy']")
37
71
 
38
72
  if (destroyInput) {
39
- // Persisted record - hide and mark for destruction
40
73
  destroyInput.value = "true"
41
74
  wrapper.style.display = "none"
42
75
  } else {
43
- // New record - remove from DOM
44
76
  wrapper.remove()
45
77
  }
46
78
 
47
79
  this.dispatch("after-remove", { detail: { wrapper } })
80
+ this.updateButtonStates()
48
81
  }
49
82
 
50
- // Private
51
-
52
- #insertFields(template, insertion, targetSelector, trigger) {
83
+ insertFields(template, insertion, targetSelector, trigger) {
53
84
  const newId = new Date().getTime()
54
85
  const content = template.replace(/NEW_RECORD/g, newId)
55
86
 
56
87
  const fragment = document.createRange().createContextualFragment(content)
57
88
  const wrapper = fragment.firstElementChild
58
89
 
59
- // Dispatch cancelable before-add event
60
90
  const beforeEvent = this.dispatch("before-add", {
61
91
  cancelable: true,
62
92
  detail: { wrapper }
@@ -78,10 +108,42 @@ export default class extends Controller {
78
108
  case "prepend":
79
109
  container.prepend(fragment)
80
110
  break
81
- default: // "before"
111
+ default:
82
112
  trigger.before(fragment)
83
113
  }
84
114
 
85
115
  this.dispatch("after-add", { detail: { wrapper } })
86
116
  }
117
+
118
+ updateButtonStates() {
119
+ const atMax = this.currentCount >= this.maxValue
120
+ const atMin = this.currentCount <= this.minValue
121
+
122
+ this.addButtons.forEach(button => {
123
+ this.applyLimitState(button, atMax)
124
+ })
125
+
126
+ this.removeButtons.forEach(button => {
127
+ const wrapper = button.closest(`.${this.wrapperClassValue}`)
128
+ if (wrapper && wrapper.style.display !== "none") {
129
+ this.applyLimitState(button, atMin)
130
+ }
131
+ })
132
+ }
133
+
134
+ applyLimitState(button, isAtLimit) {
135
+ switch (this.limitBehaviorValue) {
136
+ case "hide":
137
+ button.style.display = isAtLimit ? "none" : ""
138
+ button.disabled = false
139
+ break
140
+ case "error":
141
+ button.disabled = false
142
+ button.style.display = ""
143
+ break
144
+ default: // "disable"
145
+ button.disabled = isAtLimit
146
+ button.style.display = ""
147
+ }
148
+ }
87
149
  }
@@ -11,8 +11,19 @@ module HotwireNestedForm
11
11
  builder_class.include?('SimpleForm')
12
12
  end
13
13
 
14
+ def formtastic?(form_builder)
15
+ return false unless form_builder
16
+
17
+ builder_class = form_builder.class.name.to_s
18
+ builder_class.include?('Formtastic')
19
+ end
20
+
14
21
  def simple_form_available?
15
22
  defined?(::SimpleForm) ? true : false
16
23
  end
24
+
25
+ def formtastic_available?
26
+ defined?(::Formtastic) ? true : false
27
+ end
17
28
  end
18
29
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module HotwireNestedForm
4
- VERSION = '1.1.0'
4
+ VERSION = '1.2.0'
5
5
  end
data/npm/README.md CHANGED
@@ -1,13 +1,13 @@
1
- # @hotwire-nested-form/stimulus
1
+ # hotwire-nested-form-stimulus
2
2
 
3
3
  A Stimulus controller for dynamic nested forms. Add and remove nested form fields with ease.
4
4
 
5
5
  ## Installation
6
6
 
7
7
  ```bash
8
- npm install @hotwire-nested-form/stimulus
8
+ npm install hotwire-nested-form-stimulus
9
9
  # or
10
- yarn add @hotwire-nested-form/stimulus
10
+ yarn add hotwire-nested-form-stimulus
11
11
  ```
12
12
 
13
13
  ## Usage
@@ -16,7 +16,7 @@ yarn add @hotwire-nested-form/stimulus
16
16
 
17
17
  ```javascript
18
18
  import { Application } from "@hotwired/stimulus"
19
- import NestedFormController from "@hotwire-nested-form/stimulus"
19
+ import NestedFormController from "hotwire-nested-form-stimulus"
20
20
 
21
21
  const application = Application.start()
22
22
  application.register("nested-form", NestedFormController)
@@ -47,6 +47,23 @@ application.register("nested-form", NestedFormController)
47
47
  | `data-count` | Number of fields to add per click | `1` |
48
48
  | `data-target` | CSS selector for insertion container | Parent element |
49
49
 
50
+ ### Min/Max Limits
51
+
52
+ ```html
53
+ <div data-controller="nested-form"
54
+ data-nested-form-min-value="1"
55
+ data-nested-form-max-value="5"
56
+ data-nested-form-limit-behavior-value="disable">
57
+ <!-- fields here -->
58
+ </div>
59
+ ```
60
+
61
+ | Attribute | Description | Default |
62
+ |-----------|-------------|---------|
63
+ | `data-nested-form-min-value` | Minimum items required | `0` |
64
+ | `data-nested-form-max-value` | Maximum items allowed | unlimited |
65
+ | `data-nested-form-limit-behavior-value` | `"disable"`, `"hide"`, or `"error"` | `"disable"` |
66
+
50
67
  ### Events
51
68
 
52
69
  | Event | Cancelable | Detail |
@@ -55,6 +72,8 @@ application.register("nested-form", NestedFormController)
55
72
  | `nested-form:after-add` | No | `{ wrapper }` |
56
73
  | `nested-form:before-remove` | Yes | `{ wrapper }` |
57
74
  | `nested-form:after-remove` | No | `{ wrapper }` |
75
+ | `nested-form:limit-reached` | No | `{ limit, current }` |
76
+ | `nested-form:minimum-reached` | No | `{ minimum, current }` |
58
77
 
59
78
  ### Example: Listen for Events
60
79
 
data/npm/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
- "name": "@hotwire-nested-form/stimulus",
3
- "version": "1.1.0",
2
+ "name": "hotwire-nested-form-stimulus",
3
+ "version": "1.2.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",
@@ -2,25 +2,61 @@ import { Controller } from "@hotwired/stimulus"
2
2
 
3
3
  export default class extends Controller {
4
4
  static values = {
5
- wrapperClass: { type: String, default: "nested-fields" }
5
+ wrapperClass: { type: String, default: "nested-fields" },
6
+ min: { type: Number, default: 0 },
7
+ max: { type: Number, default: 999999 },
8
+ limitBehavior: { type: String, default: "disable" }
9
+ }
10
+
11
+ connect() {
12
+ this.updateButtonStates()
13
+ }
14
+
15
+ get currentCount() {
16
+ return this.element.querySelectorAll(`.${this.wrapperClassValue}:not([style*="display: none"])`).length
17
+ }
18
+
19
+ get addButtons() {
20
+ return this.element.querySelectorAll('[data-action*="nested-form#add"]')
21
+ }
22
+
23
+ get removeButtons() {
24
+ return this.element.querySelectorAll('[data-action*="nested-form#remove"]')
6
25
  }
7
26
 
8
27
  add(event) {
9
28
  event.preventDefault()
10
29
 
30
+ if (this.currentCount >= this.maxValue) {
31
+ this.dispatch("limit-reached", {
32
+ detail: { limit: this.maxValue, current: this.currentCount }
33
+ })
34
+ return
35
+ }
36
+
11
37
  const template = event.currentTarget.dataset.template
12
38
  const insertion = event.currentTarget.dataset.insertion || "before"
13
39
  const targetSelector = event.currentTarget.dataset.target
14
40
  const count = parseInt(event.currentTarget.dataset.count) || 1
15
41
 
16
42
  for (let i = 0; i < count; i++) {
43
+ if (this.currentCount >= this.maxValue) break
17
44
  this.insertFields(template, insertion, targetSelector, event.currentTarget)
18
45
  }
46
+
47
+ this.updateButtonStates()
19
48
  }
20
49
 
21
50
  remove(event) {
22
51
  event.preventDefault()
23
52
 
53
+ if (this.currentCount <= this.minValue) {
54
+ this.dispatch("minimum-reached", {
55
+ detail: { minimum: this.minValue, current: this.currentCount }
56
+ })
57
+ return
58
+ }
59
+
24
60
  const wrapper = event.currentTarget.closest(`.${this.wrapperClassValue}`)
25
61
  if (!wrapper) return
26
62
 
@@ -41,6 +77,7 @@ export default class extends Controller {
41
77
  }
42
78
 
43
79
  this.dispatch("after-remove", { detail: { wrapper } })
80
+ this.updateButtonStates()
44
81
  }
45
82
 
46
83
  insertFields(template, insertion, targetSelector, trigger) {
@@ -77,4 +114,36 @@ export default class extends Controller {
77
114
 
78
115
  this.dispatch("after-add", { detail: { wrapper } })
79
116
  }
117
+
118
+ updateButtonStates() {
119
+ const atMax = this.currentCount >= this.maxValue
120
+ const atMin = this.currentCount <= this.minValue
121
+
122
+ this.addButtons.forEach(button => {
123
+ this.applyLimitState(button, atMax)
124
+ })
125
+
126
+ this.removeButtons.forEach(button => {
127
+ const wrapper = button.closest(`.${this.wrapperClassValue}`)
128
+ if (wrapper && wrapper.style.display !== "none") {
129
+ this.applyLimitState(button, atMin)
130
+ }
131
+ })
132
+ }
133
+
134
+ applyLimitState(button, isAtLimit) {
135
+ switch (this.limitBehaviorValue) {
136
+ case "hide":
137
+ button.style.display = isAtLimit ? "none" : ""
138
+ button.disabled = false
139
+ break
140
+ case "error":
141
+ button.disabled = false
142
+ button.style.display = ""
143
+ break
144
+ default: // "disable"
145
+ button.disabled = isAtLimit
146
+ button.style.display = ""
147
+ }
148
+ }
80
149
  }
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: hotwire_nested_form
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.1.0
4
+ version: 1.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - BhumitBhadani