blanks 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/CHANGELOG.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +427 -0
- data/Rakefile +8 -0
- data/examples/advanced_features_example.rb +81 -0
- data/examples/assign_from_model_example.rb +54 -0
- data/examples/model_name_example.rb +31 -0
- data/examples/normalization_example.rb +39 -0
- data/examples/post_form_example.rb +52 -0
- data/lib/blanks/association_proxy.rb +92 -0
- data/lib/blanks/associations.rb +58 -0
- data/lib/blanks/base.rb +256 -0
- data/lib/blanks/model_naming.rb +24 -0
- data/lib/blanks/nested_attributes.rb +131 -0
- data/lib/blanks/normalization.rb +38 -0
- data/lib/blanks/version.rb +3 -0
- data/lib/blanks.rb +18 -0
- data/spec/blanks/association_proxy_spec.rb +214 -0
- data/spec/blanks/associations_spec.rb +185 -0
- data/spec/blanks/attributes_extraction_spec.rb +138 -0
- data/spec/blanks/base_spec.rb +361 -0
- data/spec/blanks/callbacks_spec.rb +60 -0
- data/spec/blanks/custom_primary_key_spec.rb +168 -0
- data/spec/blanks/dirty_tracking_spec.rb +61 -0
- data/spec/blanks/i18n_spec.rb +33 -0
- data/spec/blanks/id_tracking_spec.rb +168 -0
- data/spec/blanks/inherit_attributes_from_spec.rb +148 -0
- data/spec/blanks/inherit_validations_from_spec.rb +260 -0
- data/spec/blanks/model_naming_spec.rb +82 -0
- data/spec/blanks/nested_attributes_spec.rb +378 -0
- data/spec/blanks/normalization_spec.rb +122 -0
- data/spec/dummy/Gemfile +10 -0
- data/spec/dummy/Gemfile.lock +242 -0
- data/spec/dummy/Rakefile +5 -0
- data/spec/dummy/app/controllers/application_controller.rb +4 -0
- data/spec/dummy/app/controllers/simple_form/articles_controller.rb +65 -0
- data/spec/dummy/app/controllers/simple_form/posts_controller.rb +59 -0
- data/spec/dummy/app/controllers/standard/articles_controller.rb +65 -0
- data/spec/dummy/app/controllers/standard/posts_controller.rb +59 -0
- data/spec/dummy/app/forms/article_form.rb +17 -0
- data/spec/dummy/app/forms/cover_image_form.rb +9 -0
- data/spec/dummy/app/forms/post_form.rb +10 -0
- data/spec/dummy/app/forms/tag_form.rb +12 -0
- data/spec/dummy/app/models/application_record.rb +5 -0
- data/spec/dummy/app/models/article.rb +11 -0
- data/spec/dummy/app/models/cover_image.rb +7 -0
- data/spec/dummy/app/models/post.rb +8 -0
- data/spec/dummy/app/models/tag.rb +7 -0
- data/spec/dummy/app/views/layouts/application.html.erb +43 -0
- data/spec/dummy/app/views/simple_form/articles/_form.html.erb +43 -0
- data/spec/dummy/app/views/simple_form/articles/edit.html.erb +5 -0
- data/spec/dummy/app/views/simple_form/articles/index.html.erb +29 -0
- data/spec/dummy/app/views/simple_form/articles/new.html.erb +5 -0
- data/spec/dummy/app/views/simple_form/articles/show.html.erb +29 -0
- data/spec/dummy/app/views/simple_form/posts/_form.html.erb +19 -0
- data/spec/dummy/app/views/simple_form/posts/edit.html.erb +5 -0
- data/spec/dummy/app/views/simple_form/posts/index.html.erb +27 -0
- data/spec/dummy/app/views/simple_form/posts/new.html.erb +5 -0
- data/spec/dummy/app/views/simple_form/posts/show.html.erb +12 -0
- data/spec/dummy/app/views/standard/articles/_form.html.erb +61 -0
- data/spec/dummy/app/views/standard/articles/edit.html.erb +5 -0
- data/spec/dummy/app/views/standard/articles/index.html.erb +29 -0
- data/spec/dummy/app/views/standard/articles/new.html.erb +5 -0
- data/spec/dummy/app/views/standard/articles/show.html.erb +29 -0
- data/spec/dummy/app/views/standard/posts/_form.html.erb +30 -0
- data/spec/dummy/app/views/standard/posts/edit.html.erb +5 -0
- data/spec/dummy/app/views/standard/posts/index.html.erb +27 -0
- data/spec/dummy/app/views/standard/posts/new.html.erb +5 -0
- data/spec/dummy/app/views/standard/posts/show.html.erb +12 -0
- data/spec/dummy/bin/rails +6 -0
- data/spec/dummy/config/application.rb +18 -0
- data/spec/dummy/config/boot.rb +5 -0
- data/spec/dummy/config/database.yml +12 -0
- data/spec/dummy/config/environment.rb +5 -0
- data/spec/dummy/config/environments/development.rb +9 -0
- data/spec/dummy/config/environments/test.rb +8 -0
- data/spec/dummy/config/initializers/simple_form.rb +21 -0
- data/spec/dummy/config/routes.rb +15 -0
- data/spec/dummy/config/storage.yml +3 -0
- data/spec/dummy/config.ru +5 -0
- data/spec/dummy/db/migrate/1_create_posts.rb +12 -0
- data/spec/dummy/db/migrate/2_create_articles.rb +12 -0
- data/spec/dummy/db/migrate/3_create_cover_images.rb +12 -0
- data/spec/dummy/db/migrate/4_create_tags.rb +12 -0
- data/spec/dummy/db/migrate/5_create_active_storage_tables.rb +36 -0
- data/spec/dummy/db/schema.rb +82 -0
- data/spec/dummy/spec/examples.txt +145 -0
- data/spec/dummy/tmp/local_secret.txt +1 -0
- data/spec/examples.txt +157 -0
- data/spec/spec_helper.rb +21 -0
- metadata +159 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: b58ed4c7ffd2e891ca62e830ddd3e79b4e5e7be4b4e7104df56e684c0f5aaf3b
|
|
4
|
+
data.tar.gz: 4a594bbe4cf7d48b68c1d55ddfe14732485550db608f1b346bee270b599bdd16
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: ed2a22175361ecd5463534b5db6dd01b5cc43cccbae9090cef91bbed90df16f998727290d210835be090307dae929793a9bec10772e714b4eea82fbb39df5a9a
|
|
7
|
+
data.tar.gz: a1ccc03c12fad3bf59a3d5bee8ccfbc55f6e340aea1f360af903d806194e49322cc31803bdc3bf30c5df2eaee452bc1de216ea1436651fff83bce6c58cde95aa
|
data/CHANGELOG.md
ADDED
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Josh
|
|
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
|
|
13
|
+
all 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
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,427 @@
|
|
|
1
|
+
# Blanks
|
|
2
|
+
|
|
3
|
+
_fill it in_.
|
|
4
|
+
|
|
5
|
+
Blanks is a form object pattern that works with Rails form helpers and validations without requiring database persistence.
|
|
6
|
+
|
|
7
|
+
Most forms don't map to a single database table; some don't map to the database at all. This gem provides form objects that implement the ActiveModel interface—associations, validations, nested attributes, dirty tracking—without requiring ActiveRecord.
|
|
8
|
+
|
|
9
|
+
Built on ActiveModel, so the Rails conventions you already know work here. Use the same validations, callbacks, attribute types, and form helpers. Forms integrate with `form_with` and `fields_for` without configuration. Error handling, i18n, dirty tracking, and model naming all follow standard Rails patterns.
|
|
10
|
+
|
|
11
|
+
## Installation
|
|
12
|
+
|
|
13
|
+
You've done this before:
|
|
14
|
+
|
|
15
|
+
```ruby
|
|
16
|
+
gem 'blanks'
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
## Usage
|
|
21
|
+
|
|
22
|
+
### Basic form with attributes
|
|
23
|
+
|
|
24
|
+
```ruby
|
|
25
|
+
class PostForm < Blanks::Base
|
|
26
|
+
attribute :title, :string
|
|
27
|
+
attribute :content, :string
|
|
28
|
+
attribute :created_at, :datetime, default: -> { Time.current }
|
|
29
|
+
|
|
30
|
+
validates :title, presence: true
|
|
31
|
+
validates :content, presence: true
|
|
32
|
+
validates :created_at, presence: true
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
form = PostForm.new(title: "hello", content: "world")
|
|
36
|
+
form.valid? # true
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
### Inheriting attributes from models
|
|
40
|
+
|
|
41
|
+
Pull attribute definitions from existing models instead of manually defining each one.
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
rails g model Post title:string content:text
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
```ruby
|
|
48
|
+
class Post < ApplicationRecord; end
|
|
49
|
+
|
|
50
|
+
class PostForm < Blanks::Base
|
|
51
|
+
inherit_attributes_from Post, except: [:created_at, :updated_at]
|
|
52
|
+
end
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Use `only:` to include specific attributes:
|
|
56
|
+
|
|
57
|
+
```ruby
|
|
58
|
+
class PostForm < Blanks::Base
|
|
59
|
+
inherit_attributes_from Post, only: [:title, :content]
|
|
60
|
+
end
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Inherited attributes work with `from_model` and preserve the model's attribute types.
|
|
64
|
+
|
|
65
|
+
### Inheriting validations from models
|
|
66
|
+
|
|
67
|
+
Copy validation rules from existing models.
|
|
68
|
+
|
|
69
|
+
```ruby
|
|
70
|
+
class Post < ApplicationRecord
|
|
71
|
+
validates :title, presence: true, length: { minimum: 3 }
|
|
72
|
+
validates :content, presence: true
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
class PostForm < Blanks::Base
|
|
76
|
+
inherit_attributes_from Post, only: [:title, :content]
|
|
77
|
+
inherit_validations_from Post, only: [:title]
|
|
78
|
+
|
|
79
|
+
validates :content, presence: true, length: { minimum: 50 }
|
|
80
|
+
end
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Use `only:` and `except:` to control which validations are inherited.
|
|
84
|
+
|
|
85
|
+
#### What doesn't copy
|
|
86
|
+
|
|
87
|
+
Some validators are skipped because they don't translate cleanly to form objects:
|
|
88
|
+
|
|
89
|
+
**Proc/lambda conditionals** - Validators with `if: -> { ... }` or `unless: -> { ... }` are skipped. The closure captures the source class context and won't work correctly on the form.
|
|
90
|
+
|
|
91
|
+
```ruby
|
|
92
|
+
validates :content, presence: true, if: -> { published? }
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
**Symbol conditionals** work fine, but the form must define the method:
|
|
96
|
+
|
|
97
|
+
```ruby
|
|
98
|
+
validates :content, presence: true, if: :published?
|
|
99
|
+
|
|
100
|
+
def published?
|
|
101
|
+
status == "published"
|
|
102
|
+
end
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
**Association validators** - `validates_associated` references ActiveRecord associations that don't exist on forms. These are always skipped.
|
|
106
|
+
|
|
107
|
+
**Custom validators** work if they're `EachValidator` subclasses. Validators that call methods specific to the source model will fail at runtime.
|
|
108
|
+
|
|
109
|
+
**Missing attributes** - If you inherit a validation for an attribute that doesn't exist on the form, it will raise at runtime. Use `inherit_attributes_from` first or define the attribute manually.
|
|
110
|
+
|
|
111
|
+
### Associations
|
|
112
|
+
|
|
113
|
+
```ruby
|
|
114
|
+
class ImageForm < Blanks::Base
|
|
115
|
+
attribute :url, :string
|
|
116
|
+
validates :url, presence: true
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
class PostForm < Blanks::Base
|
|
120
|
+
has_one :cover_photo # defaults to CoverPhotoForm
|
|
121
|
+
has_many :images # defaults to ImageForm
|
|
122
|
+
|
|
123
|
+
attribute :title, :string
|
|
124
|
+
validates :title, presence: true
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
form = PostForm.new
|
|
128
|
+
form.images.new(url: "https://example.com/image.jpg")
|
|
129
|
+
form.images.count # 1
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
### Nested attributes from params
|
|
133
|
+
|
|
134
|
+
```ruby
|
|
135
|
+
params = {
|
|
136
|
+
title: "hello",
|
|
137
|
+
cover_photo_attributes: { url: "https://example.com/cover.jpg" },
|
|
138
|
+
images_attributes: [
|
|
139
|
+
{ url: "https://example.com/1.jpg" },
|
|
140
|
+
{ url: "https://example.com/2.jpg" }
|
|
141
|
+
]
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
form = PostForm.new(params)
|
|
145
|
+
form.cover_photo.url # "https://example.com/cover.jpg"
|
|
146
|
+
form.images.count # 2
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
### Load from model
|
|
150
|
+
|
|
151
|
+
Class method creates a new instance:
|
|
152
|
+
|
|
153
|
+
```ruby
|
|
154
|
+
post = Post.find(1)
|
|
155
|
+
form = PostForm.from_model(post)
|
|
156
|
+
|
|
157
|
+
form.title # value from post
|
|
158
|
+
form.cover_photo.url # value from post.cover_photo
|
|
159
|
+
form.images.count # post.images.count
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
Instance method for existing forms:
|
|
163
|
+
|
|
164
|
+
```ruby
|
|
165
|
+
form = PostForm.new
|
|
166
|
+
form.from_model(post)
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
### Validation with nested forms
|
|
170
|
+
|
|
171
|
+
Validations automatically cascade to nested forms:
|
|
172
|
+
|
|
173
|
+
```ruby
|
|
174
|
+
form = PostForm.new(title: "hello")
|
|
175
|
+
form.images.new(url: nil) # invalid image
|
|
176
|
+
|
|
177
|
+
form.valid? # false
|
|
178
|
+
form.errors.full_messages # includes nested form errors
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
### Extracting attributes for persistence
|
|
182
|
+
|
|
183
|
+
Use `model_attributes` for the form's own attributes:
|
|
184
|
+
|
|
185
|
+
```ruby
|
|
186
|
+
form = PostForm.new(title: "test")
|
|
187
|
+
form.model_attributes # => { "title" => "test", "content" => nil }
|
|
188
|
+
|
|
189
|
+
Post.create!(form.model_attributes)
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
Use `attributes` for all attributes including nested:
|
|
193
|
+
|
|
194
|
+
```ruby
|
|
195
|
+
form = PostForm.new(
|
|
196
|
+
title: "test",
|
|
197
|
+
images_attributes: [{ url: "image.jpg" }]
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
form.attributes
|
|
201
|
+
# => {
|
|
202
|
+
# "title" => "test",
|
|
203
|
+
# "content" => nil,
|
|
204
|
+
# "images_attributes" => [{ "url" => "image.jpg" }]
|
|
205
|
+
# }
|
|
206
|
+
|
|
207
|
+
Post.create!(form.attributes) # works with accepts_nested_attributes_for
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
### ID tracking in nested forms
|
|
211
|
+
|
|
212
|
+
When editing existing records, nested forms update by id:
|
|
213
|
+
|
|
214
|
+
```ruby
|
|
215
|
+
post = Post.find(1) # has images with id: 1, 2, 3
|
|
216
|
+
form = PostForm.from_model(post)
|
|
217
|
+
|
|
218
|
+
form.images_attributes = [
|
|
219
|
+
{ id: 1, url: "updated.jpg" }, # updates existing
|
|
220
|
+
{ url: "new.jpg" } # creates new
|
|
221
|
+
]
|
|
222
|
+
|
|
223
|
+
form.images.count # 4 (3 original + 1 new)
|
|
224
|
+
form.images[0].url # "updated.jpg"
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
Use `primary_key` option for non-id identifiers:
|
|
228
|
+
|
|
229
|
+
```ruby
|
|
230
|
+
class ImageForm < Blanks::Base
|
|
231
|
+
attribute :uuid, :string
|
|
232
|
+
attribute :url, :string
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
class PostForm < Blanks::Base
|
|
236
|
+
has_many :images, primary_key: :uuid
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
form = PostForm.new
|
|
240
|
+
form.images.new(uuid: "abc-123", url: "original.jpg")
|
|
241
|
+
|
|
242
|
+
form.images_attributes = [
|
|
243
|
+
{ uuid: "abc-123", url: "updated.jpg" } # updates by uuid
|
|
244
|
+
]
|
|
245
|
+
|
|
246
|
+
form.images.first.url # "updated.jpg"
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
Works with `has_one` and `has_many`. Defaults to `:id`.
|
|
250
|
+
|
|
251
|
+
### Destroying nested forms
|
|
252
|
+
|
|
253
|
+
Mark nested records for deletion with `allow_destroy: true`:
|
|
254
|
+
|
|
255
|
+
```ruby
|
|
256
|
+
class PostForm < Blanks::Base
|
|
257
|
+
has_many :images, allow_destroy: true
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
post = Post.find(1) # has images with id: 1, 2, 3
|
|
261
|
+
form = PostForm.from_model(post)
|
|
262
|
+
|
|
263
|
+
form.images_attributes = [
|
|
264
|
+
{ id: 1, url: "updated.jpg" }, # updates existing
|
|
265
|
+
{ id: 2, _destroy: true }, # marks for deletion
|
|
266
|
+
{ url: "new.jpg" } # creates new
|
|
267
|
+
]
|
|
268
|
+
|
|
269
|
+
form.images[1].marked_for_destruction? # true
|
|
270
|
+
form.attributes["images_attributes"][1]["_destroy"] # true
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
When extracting attributes, records marked for destruction include `_destroy: true`. Works with has_one and has_many.
|
|
274
|
+
|
|
275
|
+
### Dirty tracking
|
|
276
|
+
|
|
277
|
+
Track attribute changes:
|
|
278
|
+
|
|
279
|
+
```ruby
|
|
280
|
+
form = PostForm.new(title: "original")
|
|
281
|
+
form.title = "changed"
|
|
282
|
+
|
|
283
|
+
form.title_changed? # true
|
|
284
|
+
form.title_was # "original"
|
|
285
|
+
form.changes # { "title" => ["original", "changed"] }
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
### Normalization
|
|
289
|
+
|
|
290
|
+
Normalize attribute values on assignment. Works on Rails 6+, not just 7.1+.
|
|
291
|
+
|
|
292
|
+
Normalization is idempotent—applying it multiple times produces the same result as applying it once.
|
|
293
|
+
|
|
294
|
+
```ruby
|
|
295
|
+
class UserForm < Blanks::Base
|
|
296
|
+
attribute :email, :string
|
|
297
|
+
attribute :phone, :string
|
|
298
|
+
|
|
299
|
+
normalizes :email, with: ->(email) { email.strip.downcase }
|
|
300
|
+
normalizes :phone, with: ->(phone) { phone.delete("^0-9").delete_prefix("1") }
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
form = UserForm.new(email: " TEST@EXAMPLE.COM\n")
|
|
304
|
+
form.email # "test@example.com"
|
|
305
|
+
|
|
306
|
+
form = UserForm.new(phone: "1-555-123-4567")
|
|
307
|
+
form.phone # "5551234567"
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
Normalize multiple attributes with one call:
|
|
311
|
+
|
|
312
|
+
```ruby
|
|
313
|
+
class PostForm < Blanks::Base
|
|
314
|
+
attribute :title, :string
|
|
315
|
+
attribute :author, :string
|
|
316
|
+
|
|
317
|
+
normalizes :title, :author, with: ->(value) { value.strip }
|
|
318
|
+
end
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
By default, normalization skips nil values. Override with `apply_to_nil: true`:
|
|
322
|
+
|
|
323
|
+
```ruby
|
|
324
|
+
normalizes :email, with: ->(email) { email || "default@example.com" }, apply_to_nil: true
|
|
325
|
+
```
|
|
326
|
+
|
|
327
|
+
### Callbacks
|
|
328
|
+
|
|
329
|
+
Hook into the validation lifecycle:
|
|
330
|
+
|
|
331
|
+
```ruby
|
|
332
|
+
class PostForm < Blanks::Base
|
|
333
|
+
attribute :title, :string
|
|
334
|
+
|
|
335
|
+
before_validation :normalize_title
|
|
336
|
+
after_validation :log_errors
|
|
337
|
+
|
|
338
|
+
def normalize_title
|
|
339
|
+
self.title = title&.strip&.downcase
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
def log_errors
|
|
343
|
+
Rails.logger.error(errors.full_messages) if errors.any?
|
|
344
|
+
end
|
|
345
|
+
end
|
|
346
|
+
```
|
|
347
|
+
|
|
348
|
+
### Rails form integration
|
|
349
|
+
|
|
350
|
+
#### model naming
|
|
351
|
+
|
|
352
|
+
Form classes automatically strip the "Form" suffix for Rails form helpers:
|
|
353
|
+
|
|
354
|
+
```ruby
|
|
355
|
+
class PostForm < Blanks::Base
|
|
356
|
+
attribute :title, :string
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
form = PostForm.new
|
|
360
|
+
form.model_name.param_key # "post"
|
|
361
|
+
# rails generates: <input name="post[title]">
|
|
362
|
+
```
|
|
363
|
+
|
|
364
|
+
Override when needed:
|
|
365
|
+
|
|
366
|
+
```ruby
|
|
367
|
+
class AdminArticleForm < Blanks::Base
|
|
368
|
+
model_name_for :article
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
form.model_name.param_key # "article"
|
|
372
|
+
```
|
|
373
|
+
|
|
374
|
+
#### persistence detection
|
|
375
|
+
|
|
376
|
+
Forms automatically detect persistence via the `id` attribute:
|
|
377
|
+
|
|
378
|
+
```ruby
|
|
379
|
+
class PostForm < Blanks::Base
|
|
380
|
+
attribute :id, :integer
|
|
381
|
+
attribute :title, :string
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
form = PostForm.new
|
|
385
|
+
form.persisted? # false
|
|
386
|
+
|
|
387
|
+
form = PostForm.new(id: 123)
|
|
388
|
+
form.persisted? # true
|
|
389
|
+
form.to_param # "123"
|
|
390
|
+
```
|
|
391
|
+
|
|
392
|
+
Override for custom logic:
|
|
393
|
+
|
|
394
|
+
```ruby
|
|
395
|
+
class PostForm < Blanks::Base
|
|
396
|
+
def persisted?
|
|
397
|
+
# custom logic
|
|
398
|
+
end
|
|
399
|
+
|
|
400
|
+
def to_param
|
|
401
|
+
# custom logic
|
|
402
|
+
end
|
|
403
|
+
end
|
|
404
|
+
```
|
|
405
|
+
|
|
406
|
+
#### i18n
|
|
407
|
+
|
|
408
|
+
Standard ActiveModel translation support:
|
|
409
|
+
|
|
410
|
+
```yaml
|
|
411
|
+
# config/locales/en.yml
|
|
412
|
+
en:
|
|
413
|
+
activemodel:
|
|
414
|
+
attributes:
|
|
415
|
+
post: # uses model_name, not PostForm
|
|
416
|
+
title: "Post Title"
|
|
417
|
+
errors:
|
|
418
|
+
models:
|
|
419
|
+
post:
|
|
420
|
+
attributes:
|
|
421
|
+
title:
|
|
422
|
+
blank: "must be provided"
|
|
423
|
+
```
|
|
424
|
+
|
|
425
|
+
## License
|
|
426
|
+
|
|
427
|
+
MIT.
|
data/Rakefile
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "blanks"
|
|
4
|
+
|
|
5
|
+
class ImageForm < Blanks::Base
|
|
6
|
+
attribute :id, :integer
|
|
7
|
+
attribute :url, :string
|
|
8
|
+
attribute :caption, :string
|
|
9
|
+
|
|
10
|
+
validates :url, presence: true
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
class PostForm < Blanks::Base
|
|
14
|
+
has_many :images
|
|
15
|
+
|
|
16
|
+
attribute :id, :integer
|
|
17
|
+
attribute :title, :string
|
|
18
|
+
attribute :content, :string
|
|
19
|
+
|
|
20
|
+
validates :title, presence: true
|
|
21
|
+
|
|
22
|
+
before_validation :normalize_title
|
|
23
|
+
|
|
24
|
+
def normalize_title
|
|
25
|
+
self.title = title&.strip if title
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
puts "=== dirty tracking ==="
|
|
30
|
+
form = PostForm.new(title: "original")
|
|
31
|
+
puts "initial: #{form.title}"
|
|
32
|
+
|
|
33
|
+
form.title = "changed"
|
|
34
|
+
puts "changed?: #{form.title_changed?}"
|
|
35
|
+
puts "was: #{form.title_was}"
|
|
36
|
+
puts "changes: #{form.changes.inspect}"
|
|
37
|
+
|
|
38
|
+
puts "\n=== callbacks ==="
|
|
39
|
+
form = PostForm.new(title: " HELLO ")
|
|
40
|
+
form.valid?
|
|
41
|
+
puts "normalized title: #{form.title}"
|
|
42
|
+
|
|
43
|
+
puts "\n=== model_attributes (just top-level) ==="
|
|
44
|
+
form = PostForm.new(title: "test", content: "content")
|
|
45
|
+
form.images.new(url: "image.jpg")
|
|
46
|
+
puts form.model_attributes.inspect
|
|
47
|
+
|
|
48
|
+
puts "\n=== attributes (includes nested) ==="
|
|
49
|
+
puts form.attributes.inspect
|
|
50
|
+
|
|
51
|
+
puts "\n=== id tracking in nested forms ==="
|
|
52
|
+
mock_image1 = Struct.new(:id, :url, :caption).new(1, "original1.jpg", "first")
|
|
53
|
+
mock_image2 = Struct.new(:id, :url, :caption).new(2, "original2.jpg", "second")
|
|
54
|
+
mock_post = Struct.new(:id, :title, :content, :images).new(
|
|
55
|
+
100,
|
|
56
|
+
"my post",
|
|
57
|
+
"my content",
|
|
58
|
+
[mock_image1, mock_image2]
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
form = PostForm.from_model(mock_post)
|
|
62
|
+
puts "loaded from model, images count: #{form.images.count}"
|
|
63
|
+
|
|
64
|
+
form.images_attributes = [
|
|
65
|
+
{ id: 1, url: "updated1.jpg" },
|
|
66
|
+
{ url: "new.jpg", caption: "new image" }
|
|
67
|
+
]
|
|
68
|
+
|
|
69
|
+
puts "after update, images count: #{form.images.count}"
|
|
70
|
+
puts "image 1 url: #{form.images[0].url}"
|
|
71
|
+
puts "image 2 url: #{form.images[1].url}"
|
|
72
|
+
puts "image 3 url: #{form.images[2].url}"
|
|
73
|
+
|
|
74
|
+
puts "\n=== using with activerecord ==="
|
|
75
|
+
puts "for create:"
|
|
76
|
+
puts "Post.create!(form.attributes)"
|
|
77
|
+
puts form.attributes.inspect
|
|
78
|
+
|
|
79
|
+
puts "\nfor update:"
|
|
80
|
+
puts "post.update!(form.model_attributes)"
|
|
81
|
+
puts form.model_attributes.inspect
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "blanks"
|
|
4
|
+
|
|
5
|
+
class ImageForm < Blanks::Base
|
|
6
|
+
attribute :url, :string
|
|
7
|
+
attribute :caption, :string
|
|
8
|
+
|
|
9
|
+
validates :url, presence: true
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
class CoverPhotoForm < Blanks::Base
|
|
13
|
+
attribute :url, :string
|
|
14
|
+
|
|
15
|
+
validates :url, presence: true
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
class PostForm < Blanks::Base
|
|
19
|
+
has_one :cover_photo
|
|
20
|
+
has_many :images
|
|
21
|
+
|
|
22
|
+
attribute :title, :string
|
|
23
|
+
attribute :content, :string
|
|
24
|
+
attribute :created_at, :datetime
|
|
25
|
+
|
|
26
|
+
validates :title, presence: true
|
|
27
|
+
validates :content, presence: true
|
|
28
|
+
validates :created_at, presence: true
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
MockImage = Struct.new(:url, :caption, keyword_init: true)
|
|
32
|
+
MockCoverPhoto = Struct.new(:url, keyword_init: true)
|
|
33
|
+
MockPost = Struct.new(:title, :content, :created_at, :cover_photo, :images, keyword_init: true)
|
|
34
|
+
|
|
35
|
+
mock_post = MockPost.new(
|
|
36
|
+
title: "model title",
|
|
37
|
+
content: "model content",
|
|
38
|
+
created_at: Time.now,
|
|
39
|
+
cover_photo: MockCoverPhoto.new(url: "https://example.com/model-cover.jpg"),
|
|
40
|
+
images: [
|
|
41
|
+
MockImage.new(url: "https://example.com/model-1.jpg", caption: "model image 1"),
|
|
42
|
+
MockImage.new(url: "https://example.com/model-2.jpg", caption: "model image 2")
|
|
43
|
+
]
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
form = PostForm.from_model(mock_post)
|
|
47
|
+
|
|
48
|
+
puts "form valid: #{form.valid?}"
|
|
49
|
+
puts "title: #{form.title}"
|
|
50
|
+
puts "content: #{form.content}"
|
|
51
|
+
puts "cover photo url: #{form.cover_photo.url}"
|
|
52
|
+
puts "images count: #{form.images.count}"
|
|
53
|
+
puts "first image url: #{form.images[0].url}"
|
|
54
|
+
puts "first image caption: #{form.images[0].caption}"
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "blanks"
|
|
4
|
+
|
|
5
|
+
class PostForm < Blanks::Base
|
|
6
|
+
attribute :id, :integer
|
|
7
|
+
attribute :title, :string
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
class AdminArticleForm < Blanks::Base
|
|
11
|
+
model_name_for :article
|
|
12
|
+
|
|
13
|
+
attribute :id, :integer
|
|
14
|
+
attribute :title, :string
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
form1 = PostForm.new
|
|
18
|
+
puts "postform model_name: #{form1.model_name}"
|
|
19
|
+
puts "postform model_name.param_key: #{form1.model_name.param_key}"
|
|
20
|
+
puts "postform persisted?: #{form1.persisted?}"
|
|
21
|
+
puts "postform to_param: #{form1.to_param.inspect}"
|
|
22
|
+
|
|
23
|
+
form2 = PostForm.new(id: 123, title: "hello")
|
|
24
|
+
puts "\npostform with id model_name: #{form2.model_name}"
|
|
25
|
+
puts "postform with id persisted?: #{form2.persisted?}"
|
|
26
|
+
puts "postform with id to_param: #{form2.to_param}"
|
|
27
|
+
puts "postform with id to_key: #{form2.to_key.inspect}"
|
|
28
|
+
|
|
29
|
+
form3 = AdminArticleForm.new
|
|
30
|
+
puts "\nadminarticleform model_name: #{form3.model_name}"
|
|
31
|
+
puts "adminarticleform model_name.param_key: #{form3.model_name.param_key}"
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "blanks"
|
|
4
|
+
|
|
5
|
+
class UserForm < Blanks::Base
|
|
6
|
+
attribute :email, :string
|
|
7
|
+
attribute :phone, :string
|
|
8
|
+
attribute :username, :string
|
|
9
|
+
|
|
10
|
+
normalizes :email, with: ->(email) { email.strip.downcase }
|
|
11
|
+
normalizes :phone, with: ->(phone) { phone.delete("^0-9").delete_prefix("1") }
|
|
12
|
+
normalizes :username, with: :downcase
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
puts "email normalization"
|
|
16
|
+
form = UserForm.new(email: " CRUISE-CONTROL@EXAMPLE.COM\n")
|
|
17
|
+
puts "input: ' CRUISE-CONTROL@EXAMPLE.COM\\n'"
|
|
18
|
+
puts "normalized: #{form.email.inspect}"
|
|
19
|
+
puts
|
|
20
|
+
|
|
21
|
+
puts "phone normalization"
|
|
22
|
+
form = UserForm.new(phone: "1-555-123-4567")
|
|
23
|
+
puts "input: '1-555-123-4567'"
|
|
24
|
+
puts "normalized: #{form.phone.inspect}"
|
|
25
|
+
puts
|
|
26
|
+
|
|
27
|
+
puts "symbol normalizer"
|
|
28
|
+
form = UserForm.new(username: "JohnDoe")
|
|
29
|
+
puts "input: 'JohnDoe'"
|
|
30
|
+
puts "normalized: #{form.username.inspect}"
|
|
31
|
+
puts
|
|
32
|
+
|
|
33
|
+
puts "idempotency test"
|
|
34
|
+
form = UserForm.new(email: " TEST@EXAMPLE.COM ")
|
|
35
|
+
puts "initial: #{form.email.inspect}"
|
|
36
|
+
form.email = form.email
|
|
37
|
+
puts "after reassignment: #{form.email.inspect}"
|
|
38
|
+
form.email = form.email
|
|
39
|
+
puts "after second reassignment: #{form.email.inspect}"
|