voids 2.0.0 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.circleci/config.yml +1 -1
- data/.rubocop_todo.yml +3 -2
- data/CHANGELOG.md +7 -0
- data/README.md +41 -1
- data/lib/voids/association_proxy.rb +16 -4
- data/lib/voids/associations.rb +1 -1
- data/lib/voids/base.rb +40 -19
- data/lib/voids/version.rb +1 -1
- data/spec/voids/association_proxy_spec.rb +63 -13
- data/spec/voids/associations_spec.rb +22 -0
- data/spec/voids/base_spec.rb +99 -1
- data/spec/voids/dirty_tracking_spec.rb +36 -0
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2ddc0ec3f6eb944d0b91f7d661887b9ef36c7b6e02aa63606cf8eafff8a6c71a
|
|
4
|
+
data.tar.gz: 8fe0046ebed0bf511b7ce893a8e2cc48269233f7c617469c6e80e262d953d249
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 522dd880a0decbaadc5f8584e84ba822cf00f777653c829f618a7626a851a0088125c45c4ec25f382e56b7bf588a114f1a7edf5978794586bebfcb8e5a076a9c
|
|
7
|
+
data.tar.gz: 8135b1214dc06dae3f6e2e691a250a30a4708de8b661cc1b82ad96f05020ce5b10cfc1c6586e6044dd12fa0fdb94c823ef6851df4ee20acec95e7f9916fa11be
|
data/.circleci/config.yml
CHANGED
data/.rubocop_todo.yml
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# This configuration was generated by
|
|
2
2
|
# `rubocop --auto-gen-config --auto-gen-only-exclude`
|
|
3
|
-
# on 2026-09-14
|
|
3
|
+
# on 2026-09-14 23:45:19 UTC using RuboCop version 1.91.0.
|
|
4
4
|
# The point is for the user to remove these configuration records
|
|
5
5
|
# one by one as the offenses are removed from the code base.
|
|
6
6
|
# Note that changes in the inspected code, or installation of new
|
|
@@ -19,10 +19,11 @@ Lint/SelfAssignment:
|
|
|
19
19
|
Exclude:
|
|
20
20
|
- 'spec/voids/normalization_spec.rb'
|
|
21
21
|
|
|
22
|
-
# Offense count:
|
|
22
|
+
# Offense count: 8
|
|
23
23
|
# Configuration parameters: AllowedMethods, AllowedPatterns, CountRepeatedAttributes, Max.
|
|
24
24
|
Metrics/AbcSize:
|
|
25
25
|
Exclude:
|
|
26
|
+
- 'lib/voids/associations.rb'
|
|
26
27
|
- 'lib/voids/base.rb'
|
|
27
28
|
- 'lib/voids/nested_attributes.rb'
|
|
28
29
|
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
## [Unreleased]
|
|
2
2
|
|
|
3
|
+
- Add `changes_applied:` option to `Voids::Base#assign_attributes` so callers can bulk-assign attributes without resetting dirty tracking.
|
|
4
|
+
- Propagate validation context to nested forms during parent validation.
|
|
5
|
+
- Validate nested forms even when parent validations fail, so nested errors still bubble up.
|
|
6
|
+
- Add `Voids::Base#empty?` with default emptiness semantics based on blank attributes.
|
|
7
|
+
- Make `AssociationProxy#any?` block-aware (matching Ruby collection semantics).
|
|
8
|
+
- Add `AssociationProxy#reverse` for array-like read access without explicit `to_a` conversion.
|
|
9
|
+
|
|
3
10
|
## [1.0.0] - 2026-01-13
|
|
4
11
|
|
|
5
12
|
- Initial release
|
data/README.md
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
[](https://badge.fury.io/rb/voids)
|
|
2
|
+
[](https://dl.circleci.com/status-badge/redirect/gh/sul-dlss/voids/tree/main)
|
|
3
|
+
[](https://codecov.io/github/sul-dlss/voids)
|
|
4
|
+
|
|
1
5
|
# Voids
|
|
2
6
|
|
|
3
7
|
_fill it in_.
|
|
@@ -36,6 +40,9 @@ form = PostForm.new(title: "hello", content: "world")
|
|
|
36
40
|
form.valid? # true
|
|
37
41
|
```
|
|
38
42
|
|
|
43
|
+
`Voids::Base` includes a default `empty?` implementation: a form is empty when all `attributes` values are blank.
|
|
44
|
+
Override this in subclasses if your form uses custom emptiness semantics.
|
|
45
|
+
|
|
39
46
|
### Inheriting attributes from models
|
|
40
47
|
|
|
41
48
|
Pull attribute definitions from existing models instead of manually defining each one.
|
|
@@ -129,6 +136,20 @@ form.images.new(url: "https://example.com/image.jpg")
|
|
|
129
136
|
form.images.count # 1
|
|
130
137
|
```
|
|
131
138
|
|
|
139
|
+
`has_many` associations return a collection proxy that supports common collection methods:
|
|
140
|
+
|
|
141
|
+
```ruby
|
|
142
|
+
form.images.any? # true/false
|
|
143
|
+
form.images.any? { |image| image.url.present? } # true/false
|
|
144
|
+
form.images.reverse # array in reverse order
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
Notes:
|
|
148
|
+
|
|
149
|
+
1. `any?` supports both no-block and block forms.
|
|
150
|
+
2. `reverse` returns a reversed array view and does not mutate the underlying proxy order.
|
|
151
|
+
3. `to_a` is still available when explicit array conversion is desired.
|
|
152
|
+
|
|
132
153
|
### Nested attributes from params
|
|
133
154
|
|
|
134
155
|
```ruby
|
|
@@ -168,13 +189,14 @@ form.from_model(post)
|
|
|
168
189
|
|
|
169
190
|
### Validation with nested forms
|
|
170
191
|
|
|
171
|
-
Validations automatically cascade to nested forms:
|
|
192
|
+
Validations automatically cascade to nested forms and use the same validation context:
|
|
172
193
|
|
|
173
194
|
```ruby
|
|
174
195
|
form = PostForm.new(title: "hello")
|
|
175
196
|
form.images.new(url: nil) # invalid image
|
|
176
197
|
|
|
177
198
|
form.valid? # false
|
|
199
|
+
form.valid?(:publish) # false
|
|
178
200
|
form.errors.full_messages # includes nested form errors
|
|
179
201
|
```
|
|
180
202
|
|
|
@@ -285,6 +307,24 @@ form.title_was # "original"
|
|
|
285
307
|
form.changes # { "title" => ["original", "changed"] }
|
|
286
308
|
```
|
|
287
309
|
|
|
310
|
+
`assign_attributes` resets dirty tracking when it finishes, so the form reports
|
|
311
|
+
itself as clean and the assignments move to `previous_changes`. Pass
|
|
312
|
+
`changes_applied: false` to keep dirty tracking intact, which is useful when the
|
|
313
|
+
caller needs to know what a params hash actually changed:
|
|
314
|
+
|
|
315
|
+
```ruby
|
|
316
|
+
form = PostForm.new(title: "original")
|
|
317
|
+
form.assign_attributes(params[:post], changes_applied: false)
|
|
318
|
+
|
|
319
|
+
form.changed? # true
|
|
320
|
+
form.changes # { "title" => ["original", "submitted"] }
|
|
321
|
+
```
|
|
322
|
+
|
|
323
|
+
Attributes can be passed positionally or as keywords; `:changes_applied` is
|
|
324
|
+
never treated as an attribute. Nested forms assigned through `*_attributes=`
|
|
325
|
+
writers still reset their own dirty tracking, since those writers follow the
|
|
326
|
+
Rails setter convention and take no options.
|
|
327
|
+
|
|
288
328
|
### Normalization
|
|
289
329
|
|
|
290
330
|
Normalize attribute values on assignment. Works on Rails 6+, not just 7.1+.
|
|
@@ -55,8 +55,14 @@ module Voids
|
|
|
55
55
|
@records.empty?
|
|
56
56
|
end
|
|
57
57
|
|
|
58
|
-
def any?
|
|
59
|
-
@records.any?
|
|
58
|
+
def any?(&block)
|
|
59
|
+
return @records.any? unless block
|
|
60
|
+
|
|
61
|
+
@records.any?(&block)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def reverse
|
|
65
|
+
@records.reverse
|
|
60
66
|
end
|
|
61
67
|
|
|
62
68
|
def [](index)
|
|
@@ -68,6 +74,12 @@ module Voids
|
|
|
68
74
|
@records_by_id.clear
|
|
69
75
|
end
|
|
70
76
|
|
|
77
|
+
def replace(records)
|
|
78
|
+
clear
|
|
79
|
+
Array(records).each { |record| push(record) }
|
|
80
|
+
self
|
|
81
|
+
end
|
|
82
|
+
|
|
71
83
|
def to_a
|
|
72
84
|
@records
|
|
73
85
|
end
|
|
@@ -80,8 +92,8 @@ module Voids
|
|
|
80
92
|
false
|
|
81
93
|
end
|
|
82
94
|
|
|
83
|
-
def valid?
|
|
84
|
-
@records.all?
|
|
95
|
+
def valid?(context = nil)
|
|
96
|
+
@records.all? { it.valid?(context) }
|
|
85
97
|
end
|
|
86
98
|
|
|
87
99
|
def errors
|
data/lib/voids/associations.rb
CHANGED
data/lib/voids/base.rb
CHANGED
|
@@ -87,7 +87,20 @@ module Voids
|
|
|
87
87
|
assign_attributes(attributes) if attributes.present?
|
|
88
88
|
end
|
|
89
89
|
|
|
90
|
-
|
|
90
|
+
# Assigns the given attributes. Afterwards the form is marked as clean
|
|
91
|
+
# (dirty tracking is reset) unless `changes_applied: false` is given.
|
|
92
|
+
#
|
|
93
|
+
# Attributes may be passed positionally (`assign_attributes(params)`) or as
|
|
94
|
+
# keywords (`assign_attributes(title: 'new')`), so `:changes_applied` is
|
|
95
|
+
# extracted from the keywords rather than declared as a keyword argument.
|
|
96
|
+
def assign_attributes(new_attributes = nil, **options)
|
|
97
|
+
changes_applied = options.fetch(:changes_applied, true)
|
|
98
|
+
keyword_attributes = options.except(:changes_applied)
|
|
99
|
+
if new_attributes && keyword_attributes.any?
|
|
100
|
+
raise ArgumentError, 'pass attributes either positionally or as keywords, not both'
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
new_attributes ||= keyword_attributes
|
|
91
104
|
return if new_attributes.blank?
|
|
92
105
|
|
|
93
106
|
attrs = if new_attributes.respond_to?(:to_unsafe_h)
|
|
@@ -109,7 +122,7 @@ module Voids
|
|
|
109
122
|
end
|
|
110
123
|
end
|
|
111
124
|
|
|
112
|
-
changes_applied
|
|
125
|
+
self.changes_applied if changes_applied
|
|
113
126
|
end
|
|
114
127
|
|
|
115
128
|
def from_model(model)
|
|
@@ -176,7 +189,9 @@ module Voids
|
|
|
176
189
|
|
|
177
190
|
def valid?(context = nil)
|
|
178
191
|
run_callbacks :validation do
|
|
179
|
-
super(context)
|
|
192
|
+
parent_valid = super(context)
|
|
193
|
+
nested_valid = nested_forms_valid?(context)
|
|
194
|
+
parent_valid && nested_valid
|
|
180
195
|
end
|
|
181
196
|
end
|
|
182
197
|
|
|
@@ -215,32 +230,38 @@ module Voids
|
|
|
215
230
|
attributes.except(*excluded_keys)
|
|
216
231
|
end
|
|
217
232
|
|
|
233
|
+
def empty?
|
|
234
|
+
attributes.all? { |_name, value| value.blank? }
|
|
235
|
+
end
|
|
236
|
+
|
|
218
237
|
private
|
|
219
238
|
|
|
220
|
-
def nested_forms_valid?
|
|
221
|
-
|
|
239
|
+
def nested_forms_valid?(context)
|
|
240
|
+
all_valid = true
|
|
241
|
+
|
|
242
|
+
self.class.associations.each do |name, association|
|
|
222
243
|
nested_form = public_send(name)
|
|
223
|
-
next
|
|
244
|
+
next if nested_form.nil?
|
|
224
245
|
|
|
225
246
|
case association[:type]
|
|
226
247
|
when :has_one
|
|
227
|
-
if nested_form.valid?
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
end
|
|
248
|
+
next if nested_form.valid?(context)
|
|
249
|
+
|
|
250
|
+
errors.add(name.to_sym, :invalid)
|
|
251
|
+
copy_nested_errors(name, nested_form)
|
|
252
|
+
all_valid = false
|
|
233
253
|
when :has_many
|
|
234
|
-
if nested_form.valid?
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
end
|
|
240
|
-
false
|
|
254
|
+
next if nested_form.valid?(context)
|
|
255
|
+
|
|
256
|
+
errors.add(name.to_sym, :invalid)
|
|
257
|
+
nested_form.each_with_index do |form, index|
|
|
258
|
+
copy_nested_errors("#{name}[#{index}]", form) unless form.valid?(context)
|
|
241
259
|
end
|
|
260
|
+
all_valid = false
|
|
242
261
|
end
|
|
243
262
|
end
|
|
263
|
+
|
|
264
|
+
all_valid
|
|
244
265
|
end
|
|
245
266
|
|
|
246
267
|
def copy_nested_errors(association_name, nested_form)
|
data/lib/voids/version.rb
CHANGED
|
@@ -148,6 +148,14 @@ RSpec.describe Voids::AssociationProxy do
|
|
|
148
148
|
|
|
149
149
|
expect(proxy.any?).to be(true)
|
|
150
150
|
end
|
|
151
|
+
|
|
152
|
+
it 'supports block form' do
|
|
153
|
+
proxy = described_class.new('ItemForm')
|
|
154
|
+
proxy.new(url: 'first')
|
|
155
|
+
proxy.new(url: nil)
|
|
156
|
+
|
|
157
|
+
expect(proxy.any? { |item| item.url.nil? }).to be(true)
|
|
158
|
+
end
|
|
151
159
|
end
|
|
152
160
|
|
|
153
161
|
describe '#[]' do
|
|
@@ -172,6 +180,29 @@ RSpec.describe Voids::AssociationProxy do
|
|
|
172
180
|
end
|
|
173
181
|
end
|
|
174
182
|
|
|
183
|
+
describe '#replace' do
|
|
184
|
+
it 'replaces records and returns self' do
|
|
185
|
+
proxy = described_class.new('ItemForm')
|
|
186
|
+
original = proxy.new(url: 'first')
|
|
187
|
+
replacement = ItemForm.new(url: 'second')
|
|
188
|
+
|
|
189
|
+
result = proxy.replace([replacement])
|
|
190
|
+
|
|
191
|
+
expect(result).to eq(proxy)
|
|
192
|
+
expect(proxy.to_a).to eq([replacement])
|
|
193
|
+
expect(proxy.to_a).not_to include(original)
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
it 'clears records when passed nil' do
|
|
197
|
+
proxy = described_class.new('ItemForm')
|
|
198
|
+
proxy.new(url: 'first')
|
|
199
|
+
|
|
200
|
+
proxy.replace(nil)
|
|
201
|
+
|
|
202
|
+
expect(proxy).to be_empty
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
|
|
175
206
|
describe '#to_a' do
|
|
176
207
|
it 'returns the underlying array' do
|
|
177
208
|
proxy = described_class.new('ItemForm')
|
|
@@ -182,32 +213,51 @@ RSpec.describe Voids::AssociationProxy do
|
|
|
182
213
|
end
|
|
183
214
|
end
|
|
184
215
|
|
|
216
|
+
describe '#reverse' do
|
|
217
|
+
it 'returns records in reverse order' do
|
|
218
|
+
proxy = described_class.new('ItemForm')
|
|
219
|
+
first = proxy.new(url: 'first')
|
|
220
|
+
second = proxy.new(url: 'second')
|
|
221
|
+
|
|
222
|
+
expect(proxy.reverse).to eq([second, first])
|
|
223
|
+
# Keep default forward iteration behavior unchanged.
|
|
224
|
+
expect(proxy.to_a).to eq([first, second])
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
|
|
185
228
|
describe '#valid?' do
|
|
186
|
-
|
|
187
|
-
|
|
229
|
+
subject(:proxy) { described_class.new('ValidForm') }
|
|
230
|
+
|
|
231
|
+
let(:valid_form_class) do
|
|
232
|
+
Class.new(Voids::Base) do
|
|
188
233
|
attribute :url, :string
|
|
189
234
|
validates :url, presence: true
|
|
235
|
+
validates :url, format: { with: /\Ahttps:.+/, message: 'must be HTTPS' }, on: :final_setup
|
|
190
236
|
end
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
before do
|
|
191
240
|
stub_const('ValidForm', valid_form_class)
|
|
192
241
|
|
|
193
|
-
proxy = described_class.new('ValidForm')
|
|
194
242
|
proxy.new(url: 'https://example.com')
|
|
243
|
+
end
|
|
195
244
|
|
|
196
|
-
|
|
245
|
+
context 'when all items are valid' do
|
|
246
|
+
it { is_expected.to be_valid }
|
|
197
247
|
end
|
|
198
248
|
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
249
|
+
context 'when validation context is set' do
|
|
250
|
+
before { proxy.new(url: 'http://example.com') }
|
|
251
|
+
|
|
252
|
+
it 'returns false' do
|
|
253
|
+
expect(proxy.valid?(:final_setup)).to be false
|
|
203
254
|
end
|
|
204
|
-
|
|
255
|
+
end
|
|
205
256
|
|
|
206
|
-
|
|
207
|
-
proxy.new(url:
|
|
208
|
-
proxy.new(url: nil)
|
|
257
|
+
context 'when any item is invalid' do
|
|
258
|
+
before { proxy.new(url: nil) }
|
|
209
259
|
|
|
210
|
-
|
|
260
|
+
it { is_expected.not_to be_valid }
|
|
211
261
|
end
|
|
212
262
|
end
|
|
213
263
|
end
|
|
@@ -181,5 +181,27 @@ RSpec.describe Voids::Associations do
|
|
|
181
181
|
|
|
182
182
|
expect(item).to be_a(CustomForm)
|
|
183
183
|
end
|
|
184
|
+
|
|
185
|
+
it 'replaces records in existing association proxy when assigned an array' do
|
|
186
|
+
nested_class = Class.new(Voids::Base) do
|
|
187
|
+
attribute :url, :string
|
|
188
|
+
end
|
|
189
|
+
stub_const('ImageForm', nested_class)
|
|
190
|
+
|
|
191
|
+
form_class = Class.new(Voids::Base) do
|
|
192
|
+
has_many :images
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
original = ImageForm.new(url: 'https://example.com/old')
|
|
196
|
+
replacement = ImageForm.new(url: 'https://example.com/new')
|
|
197
|
+
form = form_class.new
|
|
198
|
+
proxy = form.images
|
|
199
|
+
proxy << original
|
|
200
|
+
|
|
201
|
+
form.images = [replacement]
|
|
202
|
+
|
|
203
|
+
expect(form.images).to be(proxy)
|
|
204
|
+
expect(form.images.to_a).to eq([replacement])
|
|
205
|
+
end
|
|
184
206
|
end
|
|
185
207
|
end
|
data/spec/voids/base_spec.rb
CHANGED
|
@@ -85,6 +85,29 @@ RSpec.describe Voids::Base do
|
|
|
85
85
|
|
|
86
86
|
expect(form.photo.url).to eq('https://example.com')
|
|
87
87
|
end
|
|
88
|
+
|
|
89
|
+
it 'assigns keyword attributes alongside changes_applied' do
|
|
90
|
+
form_class = Class.new(described_class) do
|
|
91
|
+
attribute :title, :string
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
form = form_class.new
|
|
95
|
+
form.assign_attributes(title: 'new title', changes_applied: false)
|
|
96
|
+
|
|
97
|
+
expect(form.title).to eq('new title')
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
it 'raises when attributes are passed both positionally and as keywords' do
|
|
101
|
+
form_class = Class.new(described_class) do
|
|
102
|
+
attribute :title, :string
|
|
103
|
+
attribute :content, :string
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
form = form_class.new
|
|
107
|
+
|
|
108
|
+
expect { form.assign_attributes({ title: 'new title' }, content: 'new content') }
|
|
109
|
+
.to raise_error(ArgumentError, 'pass attributes either positionally or as keywords, not both')
|
|
110
|
+
end
|
|
88
111
|
end
|
|
89
112
|
|
|
90
113
|
describe '.from_model' do
|
|
@@ -280,6 +303,30 @@ RSpec.describe Voids::Base do
|
|
|
280
303
|
end
|
|
281
304
|
end
|
|
282
305
|
|
|
306
|
+
describe '#empty?' do
|
|
307
|
+
it 'returns true when all attributes are blank' do
|
|
308
|
+
form_class = Class.new(described_class) do
|
|
309
|
+
attribute :title, :string
|
|
310
|
+
attribute :count, :integer
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
form = form_class.new
|
|
314
|
+
|
|
315
|
+
expect(form.empty?).to be(true)
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
it 'returns false when any attribute is present' do
|
|
319
|
+
form_class = Class.new(described_class) do
|
|
320
|
+
attribute :title, :string
|
|
321
|
+
attribute :count, :integer
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
form = form_class.new(title: 'present')
|
|
325
|
+
|
|
326
|
+
expect(form.empty?).to be(false)
|
|
327
|
+
end
|
|
328
|
+
end
|
|
329
|
+
|
|
283
330
|
describe '#valid?' do
|
|
284
331
|
it 'returns true when all validations pass' do
|
|
285
332
|
form_class = Class.new(described_class) do
|
|
@@ -314,6 +361,7 @@ RSpec.describe Voids::Base do
|
|
|
314
361
|
has_one :photo
|
|
315
362
|
attribute :title, :string
|
|
316
363
|
end
|
|
364
|
+
stub_const('PhotoWrapperForm', form_class)
|
|
317
365
|
|
|
318
366
|
form = form_class.new(title: 'test')
|
|
319
367
|
form.build_photo
|
|
@@ -332,11 +380,14 @@ RSpec.describe Voids::Base do
|
|
|
332
380
|
has_many :images
|
|
333
381
|
attribute :title, :string
|
|
334
382
|
end
|
|
383
|
+
stub_const('ImageGalleryForm', form_class)
|
|
335
384
|
|
|
336
385
|
form = form_class.new(title: 'test')
|
|
337
386
|
form.images.new
|
|
338
387
|
|
|
339
388
|
expect(form.valid?).to be(false)
|
|
389
|
+
expect(form.errors.details[:images]).to include(error: :invalid)
|
|
390
|
+
expect(form.errors.messages.keys).to include(:'images[0].url')
|
|
340
391
|
end
|
|
341
392
|
|
|
342
393
|
it 'includes nested form errors in parent errors' do
|
|
@@ -350,12 +401,59 @@ RSpec.describe Voids::Base do
|
|
|
350
401
|
has_one :photo
|
|
351
402
|
attribute :title, :string
|
|
352
403
|
end
|
|
404
|
+
stub_const('PhotoWrapperForm', form_class)
|
|
353
405
|
|
|
354
406
|
form = form_class.new(title: 'test')
|
|
355
407
|
form.build_photo
|
|
356
408
|
form.valid?
|
|
357
409
|
|
|
358
|
-
expect(form.errors.messages.keys).to include(:'photo.url')
|
|
410
|
+
expect(form.errors.messages.keys).to include(:photo, :'photo.url')
|
|
411
|
+
expect(form.errors.details[:photo]).to include(error: :invalid)
|
|
412
|
+
end
|
|
413
|
+
|
|
414
|
+
it 'includes nested form errors in parent even when parent has errors' do
|
|
415
|
+
nested_class = Class.new(described_class) do
|
|
416
|
+
attribute :url, :string
|
|
417
|
+
validates :url, presence: true
|
|
418
|
+
end
|
|
419
|
+
stub_const('PhotoForm', nested_class)
|
|
420
|
+
|
|
421
|
+
form_class = Class.new(described_class) do
|
|
422
|
+
has_one :photo
|
|
423
|
+
attribute :title, :string
|
|
424
|
+
validates :title, presence: true
|
|
425
|
+
end
|
|
426
|
+
stub_const('ImageForm', form_class)
|
|
427
|
+
|
|
428
|
+
form = form_class.new
|
|
429
|
+
form.build_photo
|
|
430
|
+
form.valid?
|
|
431
|
+
|
|
432
|
+
expect(form.errors.messages.keys).to contain_exactly(:title, :photo, :'photo.url')
|
|
433
|
+
expect(form.errors.details[:photo]).to include(error: :invalid)
|
|
434
|
+
end
|
|
435
|
+
|
|
436
|
+
it 'passes the validation context to nested forms' do
|
|
437
|
+
nested_class = Class.new(described_class) do
|
|
438
|
+
attribute :url, :string
|
|
439
|
+
validates :url, presence: true
|
|
440
|
+
validates :url, format: { with: /\Ahttps:.+/, message: 'must be HTTPS' }, on: :final_setup
|
|
441
|
+
end
|
|
442
|
+
stub_const('PhotoForm', nested_class)
|
|
443
|
+
|
|
444
|
+
form_class = Class.new(described_class) do
|
|
445
|
+
has_one :photo
|
|
446
|
+
attribute :title, :string
|
|
447
|
+
validates :title, presence: true
|
|
448
|
+
end
|
|
449
|
+
stub_const('ImageForm', form_class)
|
|
450
|
+
|
|
451
|
+
form = form_class.new(title: 'Summer in Sicily')
|
|
452
|
+
form.build_photo(url: 'http://myspace.com/sicily-photos')
|
|
453
|
+
form.valid?(:final_setup)
|
|
454
|
+
|
|
455
|
+
expect(form.errors.messages.keys).to contain_exactly(:photo, :'photo.url')
|
|
456
|
+
expect(form.errors.details[:photo]).to include(error: :invalid)
|
|
359
457
|
end
|
|
360
458
|
end
|
|
361
459
|
end
|
|
@@ -49,6 +49,42 @@ RSpec.describe 'Dirty tracking' do
|
|
|
49
49
|
expect(form.changed?).to be(false)
|
|
50
50
|
end
|
|
51
51
|
|
|
52
|
+
it 'clears changes after assign_attributes when changes_applied is true' do
|
|
53
|
+
form_class = Class.new(Voids::Base) do
|
|
54
|
+
attribute :title, :string
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
form = form_class.new(title: 'original')
|
|
58
|
+
form.title = 'changed'
|
|
59
|
+
form.assign_attributes({ title: 'new' }, changes_applied: true)
|
|
60
|
+
|
|
61
|
+
expect(form.changed?).to be(false)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
it 'preserves earlier changes when changes_applied is false' do
|
|
65
|
+
form_class = Class.new(Voids::Base) do
|
|
66
|
+
attribute :title, :string
|
|
67
|
+
attribute :content, :string
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
form = form_class.new(title: 'original', content: 'original content')
|
|
71
|
+
form.title = 'changed'
|
|
72
|
+
form.assign_attributes({ content: 'new content' }, changes_applied: false)
|
|
73
|
+
|
|
74
|
+
expect(form.title_was).to eq('original')
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
it 'records the assignment as a change when changes_applied is false' do
|
|
78
|
+
form_class = Class.new(Voids::Base) do
|
|
79
|
+
attribute :title, :string
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
form = form_class.new(title: 'original')
|
|
83
|
+
form.assign_attributes({ title: 'new' }, changes_applied: false)
|
|
84
|
+
|
|
85
|
+
expect(form.changes).to eq('title' => %w[original new])
|
|
86
|
+
end
|
|
87
|
+
|
|
52
88
|
it 'detects if any attributes changed' do
|
|
53
89
|
form_class = Class.new(Voids::Base) do
|
|
54
90
|
attribute :title, :string
|