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.
Files changed (92) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +5 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +427 -0
  5. data/Rakefile +8 -0
  6. data/examples/advanced_features_example.rb +81 -0
  7. data/examples/assign_from_model_example.rb +54 -0
  8. data/examples/model_name_example.rb +31 -0
  9. data/examples/normalization_example.rb +39 -0
  10. data/examples/post_form_example.rb +52 -0
  11. data/lib/blanks/association_proxy.rb +92 -0
  12. data/lib/blanks/associations.rb +58 -0
  13. data/lib/blanks/base.rb +256 -0
  14. data/lib/blanks/model_naming.rb +24 -0
  15. data/lib/blanks/nested_attributes.rb +131 -0
  16. data/lib/blanks/normalization.rb +38 -0
  17. data/lib/blanks/version.rb +3 -0
  18. data/lib/blanks.rb +18 -0
  19. data/spec/blanks/association_proxy_spec.rb +214 -0
  20. data/spec/blanks/associations_spec.rb +185 -0
  21. data/spec/blanks/attributes_extraction_spec.rb +138 -0
  22. data/spec/blanks/base_spec.rb +361 -0
  23. data/spec/blanks/callbacks_spec.rb +60 -0
  24. data/spec/blanks/custom_primary_key_spec.rb +168 -0
  25. data/spec/blanks/dirty_tracking_spec.rb +61 -0
  26. data/spec/blanks/i18n_spec.rb +33 -0
  27. data/spec/blanks/id_tracking_spec.rb +168 -0
  28. data/spec/blanks/inherit_attributes_from_spec.rb +148 -0
  29. data/spec/blanks/inherit_validations_from_spec.rb +260 -0
  30. data/spec/blanks/model_naming_spec.rb +82 -0
  31. data/spec/blanks/nested_attributes_spec.rb +378 -0
  32. data/spec/blanks/normalization_spec.rb +122 -0
  33. data/spec/dummy/Gemfile +10 -0
  34. data/spec/dummy/Gemfile.lock +242 -0
  35. data/spec/dummy/Rakefile +5 -0
  36. data/spec/dummy/app/controllers/application_controller.rb +4 -0
  37. data/spec/dummy/app/controllers/simple_form/articles_controller.rb +65 -0
  38. data/spec/dummy/app/controllers/simple_form/posts_controller.rb +59 -0
  39. data/spec/dummy/app/controllers/standard/articles_controller.rb +65 -0
  40. data/spec/dummy/app/controllers/standard/posts_controller.rb +59 -0
  41. data/spec/dummy/app/forms/article_form.rb +17 -0
  42. data/spec/dummy/app/forms/cover_image_form.rb +9 -0
  43. data/spec/dummy/app/forms/post_form.rb +10 -0
  44. data/spec/dummy/app/forms/tag_form.rb +12 -0
  45. data/spec/dummy/app/models/application_record.rb +5 -0
  46. data/spec/dummy/app/models/article.rb +11 -0
  47. data/spec/dummy/app/models/cover_image.rb +7 -0
  48. data/spec/dummy/app/models/post.rb +8 -0
  49. data/spec/dummy/app/models/tag.rb +7 -0
  50. data/spec/dummy/app/views/layouts/application.html.erb +43 -0
  51. data/spec/dummy/app/views/simple_form/articles/_form.html.erb +43 -0
  52. data/spec/dummy/app/views/simple_form/articles/edit.html.erb +5 -0
  53. data/spec/dummy/app/views/simple_form/articles/index.html.erb +29 -0
  54. data/spec/dummy/app/views/simple_form/articles/new.html.erb +5 -0
  55. data/spec/dummy/app/views/simple_form/articles/show.html.erb +29 -0
  56. data/spec/dummy/app/views/simple_form/posts/_form.html.erb +19 -0
  57. data/spec/dummy/app/views/simple_form/posts/edit.html.erb +5 -0
  58. data/spec/dummy/app/views/simple_form/posts/index.html.erb +27 -0
  59. data/spec/dummy/app/views/simple_form/posts/new.html.erb +5 -0
  60. data/spec/dummy/app/views/simple_form/posts/show.html.erb +12 -0
  61. data/spec/dummy/app/views/standard/articles/_form.html.erb +61 -0
  62. data/spec/dummy/app/views/standard/articles/edit.html.erb +5 -0
  63. data/spec/dummy/app/views/standard/articles/index.html.erb +29 -0
  64. data/spec/dummy/app/views/standard/articles/new.html.erb +5 -0
  65. data/spec/dummy/app/views/standard/articles/show.html.erb +29 -0
  66. data/spec/dummy/app/views/standard/posts/_form.html.erb +30 -0
  67. data/spec/dummy/app/views/standard/posts/edit.html.erb +5 -0
  68. data/spec/dummy/app/views/standard/posts/index.html.erb +27 -0
  69. data/spec/dummy/app/views/standard/posts/new.html.erb +5 -0
  70. data/spec/dummy/app/views/standard/posts/show.html.erb +12 -0
  71. data/spec/dummy/bin/rails +6 -0
  72. data/spec/dummy/config/application.rb +18 -0
  73. data/spec/dummy/config/boot.rb +5 -0
  74. data/spec/dummy/config/database.yml +12 -0
  75. data/spec/dummy/config/environment.rb +5 -0
  76. data/spec/dummy/config/environments/development.rb +9 -0
  77. data/spec/dummy/config/environments/test.rb +8 -0
  78. data/spec/dummy/config/initializers/simple_form.rb +21 -0
  79. data/spec/dummy/config/routes.rb +15 -0
  80. data/spec/dummy/config/storage.yml +3 -0
  81. data/spec/dummy/config.ru +5 -0
  82. data/spec/dummy/db/migrate/1_create_posts.rb +12 -0
  83. data/spec/dummy/db/migrate/2_create_articles.rb +12 -0
  84. data/spec/dummy/db/migrate/3_create_cover_images.rb +12 -0
  85. data/spec/dummy/db/migrate/4_create_tags.rb +12 -0
  86. data/spec/dummy/db/migrate/5_create_active_storage_tables.rb +36 -0
  87. data/spec/dummy/db/schema.rb +82 -0
  88. data/spec/dummy/spec/examples.txt +145 -0
  89. data/spec/dummy/tmp/local_secret.txt +1 -0
  90. data/spec/examples.txt +157 -0
  91. data/spec/spec_helper.rb +21 -0
  92. 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
@@ -0,0 +1,5 @@
1
+ ## [Unreleased]
2
+
3
+ ## [1.0.0] - 2026-01-13
4
+
5
+ - Initial release
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,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ task default: :spec
@@ -0,0 +1,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}"