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
@@ -0,0 +1,378 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ RSpec.describe Blanks::NestedAttributes do
6
+ describe "nested attributes for has_one" do
7
+ it "assigns attributes to existing association" do
8
+ nested_class = Class.new(Blanks::Base) do
9
+ attribute :url, :string
10
+ end
11
+ stub_const("PhotoForm", nested_class)
12
+
13
+ form_class = Class.new(Blanks::Base) do
14
+ has_one :photo
15
+ end
16
+
17
+ form = form_class.new
18
+ form.build_photo
19
+ form.photo_attributes = { url: "https://example.com" }
20
+
21
+ expect(form.photo.url).to eq("https://example.com")
22
+ end
23
+
24
+ it "creates association if not present" do
25
+ nested_class = Class.new(Blanks::Base) do
26
+ attribute :url, :string
27
+ end
28
+ stub_const("PhotoForm", nested_class)
29
+
30
+ form_class = Class.new(Blanks::Base) do
31
+ has_one :photo
32
+ end
33
+
34
+ form = form_class.new
35
+ form.photo_attributes = { url: "https://example.com" }
36
+
37
+ expect(form.photo.url).to eq("https://example.com")
38
+ end
39
+
40
+ it "works through initialization" do
41
+ nested_class = Class.new(Blanks::Base) do
42
+ attribute :url, :string
43
+ end
44
+ stub_const("PhotoForm", nested_class)
45
+
46
+ form_class = Class.new(Blanks::Base) do
47
+ has_one :photo
48
+ attribute :title, :string
49
+ end
50
+
51
+ form = form_class.new(
52
+ title: "test",
53
+ photo_attributes: { url: "https://example.com" }
54
+ )
55
+
56
+ expect(form.photo.url).to eq("https://example.com")
57
+ end
58
+
59
+ it "handles blank attributes" do
60
+ nested_class = Class.new(Blanks::Base) do
61
+ attribute :url, :string
62
+ end
63
+ stub_const("PhotoForm", nested_class)
64
+
65
+ form_class = Class.new(Blanks::Base) do
66
+ has_one :photo
67
+ end
68
+
69
+ form = form_class.new
70
+ form.photo_attributes = nil
71
+
72
+ expect(form.photo).to be_nil
73
+ end
74
+ end
75
+
76
+ describe "nested attributes for has_many" do
77
+ it "creates multiple associated forms from array" do
78
+ nested_class = Class.new(Blanks::Base) do
79
+ attribute :url, :string
80
+ end
81
+ stub_const("ImageForm", nested_class)
82
+
83
+ form_class = Class.new(Blanks::Base) do
84
+ has_many :images
85
+ end
86
+
87
+ form = form_class.new
88
+ form.images_attributes = [
89
+ { url: "https://example.com/1.jpg" },
90
+ { url: "https://example.com/2.jpg" }
91
+ ]
92
+
93
+ expect(form.images.count).to eq(2)
94
+ end
95
+
96
+ it "creates multiple associated forms from hash" do
97
+ nested_class = Class.new(Blanks::Base) do
98
+ attribute :url, :string
99
+ end
100
+ stub_const("ImageForm", nested_class)
101
+
102
+ form_class = Class.new(Blanks::Base) do
103
+ has_many :images
104
+ end
105
+
106
+ form = form_class.new
107
+ form.images_attributes = {
108
+ "0" => { url: "https://example.com/1.jpg" },
109
+ "1" => { url: "https://example.com/2.jpg" }
110
+ }
111
+
112
+ expect(form.images.count).to eq(2)
113
+ end
114
+
115
+ it "works through initialization" do
116
+ nested_class = Class.new(Blanks::Base) do
117
+ attribute :url, :string
118
+ end
119
+ stub_const("ImageForm", nested_class)
120
+
121
+ form_class = Class.new(Blanks::Base) do
122
+ has_many :images
123
+ attribute :title, :string
124
+ end
125
+
126
+ form = form_class.new(
127
+ title: "test",
128
+ images_attributes: [
129
+ { url: "https://example.com/1.jpg" },
130
+ { url: "https://example.com/2.jpg" }
131
+ ]
132
+ )
133
+
134
+ expect(form.images.count).to eq(2)
135
+ end
136
+
137
+ it "handles blank attributes" do
138
+ nested_class = Class.new(Blanks::Base) do
139
+ attribute :url, :string
140
+ end
141
+ stub_const("ImageForm", nested_class)
142
+
143
+ form_class = Class.new(Blanks::Base) do
144
+ has_many :images
145
+ end
146
+
147
+ form = form_class.new
148
+ form.images_attributes = nil
149
+
150
+ expect(form.images.count).to eq(0)
151
+ end
152
+
153
+ it "supports reject_if option" do
154
+ nested_class = Class.new(Blanks::Base) do
155
+ attribute :url, :string
156
+ end
157
+ stub_const("ImageForm", nested_class)
158
+
159
+ form_class = Class.new(Blanks::Base) do
160
+ has_many :images, reject_if: ->(attrs) { attrs[:url].blank? }
161
+ end
162
+
163
+ form = form_class.new
164
+ form.images_attributes = [
165
+ { url: "https://example.com/1.jpg" },
166
+ { url: "" },
167
+ { url: "https://example.com/2.jpg" }
168
+ ]
169
+
170
+ expect(form.images.count).to eq(2)
171
+ end
172
+
173
+ it "supports reject_if with symbol" do
174
+ nested_class = Class.new(Blanks::Base) do
175
+ attribute :url, :string
176
+ end
177
+ stub_const("ImageForm", nested_class)
178
+
179
+ form_class = Class.new(Blanks::Base) do
180
+ has_many :images, reject_if: :reject_blank_url
181
+
182
+ def reject_blank_url(attrs)
183
+ attrs[:url].blank?
184
+ end
185
+ end
186
+
187
+ form = form_class.new
188
+ form.images_attributes = [
189
+ { url: "https://example.com/1.jpg" },
190
+ { url: "" }
191
+ ]
192
+
193
+ expect(form.images.count).to eq(1)
194
+ end
195
+ end
196
+
197
+ describe "allow_destroy for has_one" do
198
+ it "marks association for destruction when allow_destroy is true" do
199
+ nested_class = Class.new(Blanks::Base) do
200
+ attribute :url, :string
201
+ end
202
+ stub_const("PhotoForm", nested_class)
203
+
204
+ form_class = Class.new(Blanks::Base) do
205
+ has_one :photo, allow_destroy: true
206
+ end
207
+
208
+ form = form_class.new
209
+ form.build_photo(url: "https://example.com")
210
+ form.photo_attributes = { _destroy: true }
211
+
212
+ expect(form.photo.marked_for_destruction?).to be true
213
+ end
214
+
215
+ it "does not mark for destruction when allow_destroy is false" do
216
+ nested_class = Class.new(Blanks::Base) do
217
+ attribute :url, :string
218
+ end
219
+ stub_const("PhotoForm", nested_class)
220
+
221
+ form_class = Class.new(Blanks::Base) do
222
+ has_one :photo
223
+ end
224
+
225
+ form = form_class.new
226
+ form.build_photo(url: "https://example.com")
227
+ form.photo_attributes = { _destroy: true }
228
+
229
+ expect(form.photo.marked_for_destruction?).to be false
230
+ end
231
+
232
+ it "handles string values for _destroy" do
233
+ nested_class = Class.new(Blanks::Base) do
234
+ attribute :url, :string
235
+ end
236
+ stub_const("PhotoForm", nested_class)
237
+
238
+ form_class = Class.new(Blanks::Base) do
239
+ has_one :photo, allow_destroy: true
240
+ end
241
+
242
+ form = form_class.new
243
+ form.build_photo(url: "https://example.com")
244
+ form.photo_attributes = { _destroy: "1" }
245
+
246
+ expect(form.photo.marked_for_destruction?).to be true
247
+ end
248
+
249
+ it "includes _destroy in attributes when marked for destruction" do
250
+ nested_class = Class.new(Blanks::Base) do
251
+ attribute :url, :string
252
+ end
253
+ stub_const("PhotoForm", nested_class)
254
+
255
+ form_class = Class.new(Blanks::Base) do
256
+ has_one :photo, allow_destroy: true
257
+ end
258
+
259
+ form = form_class.new
260
+ form.build_photo(url: "https://example.com")
261
+ form.photo.mark_for_destruction
262
+
263
+ expect(form.attributes["photo_attributes"]["_destroy"]).to be true
264
+ end
265
+ end
266
+
267
+ describe "allow_destroy for has_many" do
268
+ it "marks existing record for destruction when allow_destroy is true" do
269
+ nested_class = Class.new(Blanks::Base) do
270
+ attribute :id, :integer
271
+ attribute :url, :string
272
+ end
273
+ stub_const("ImageForm", nested_class)
274
+
275
+ form_class = Class.new(Blanks::Base) do
276
+ has_many :images, allow_destroy: true
277
+ end
278
+
279
+ form = form_class.new
280
+ form.images.new(id: 1, url: "https://example.com/1.jpg")
281
+ form.images.new(id: 2, url: "https://example.com/2.jpg")
282
+
283
+ form.images_attributes = [
284
+ { id: 1, _destroy: true },
285
+ { id: 2, url: "updated.jpg" }
286
+ ]
287
+
288
+ expect(form.images[0].marked_for_destruction?).to be true
289
+ expect(form.images[1].marked_for_destruction?).to be false
290
+ end
291
+
292
+ it "does not mark for destruction when allow_destroy is false" do
293
+ nested_class = Class.new(Blanks::Base) do
294
+ attribute :id, :integer
295
+ attribute :url, :string
296
+ end
297
+ stub_const("ImageForm", nested_class)
298
+
299
+ form_class = Class.new(Blanks::Base) do
300
+ has_many :images
301
+ end
302
+
303
+ form = form_class.new
304
+ form.images.new(id: 1, url: "https://example.com/1.jpg")
305
+ form.images_attributes = [{ id: 1, _destroy: true }]
306
+
307
+ expect(form.images[0].marked_for_destruction?).to be false
308
+ end
309
+
310
+ it "ignores _destroy for new records without id" do
311
+ nested_class = Class.new(Blanks::Base) do
312
+ attribute :url, :string
313
+ end
314
+ stub_const("ImageForm", nested_class)
315
+
316
+ form_class = Class.new(Blanks::Base) do
317
+ has_many :images, allow_destroy: true
318
+ end
319
+
320
+ form = form_class.new
321
+ form.images_attributes = [
322
+ { url: "https://example.com/1.jpg", _destroy: true }
323
+ ]
324
+
325
+ expect(form.images.count).to eq(0)
326
+ end
327
+
328
+ it "includes _destroy in attributes for marked records" do
329
+ nested_class = Class.new(Blanks::Base) do
330
+ attribute :id, :integer
331
+ attribute :url, :string
332
+ end
333
+ stub_const("ImageForm", nested_class)
334
+
335
+ form_class = Class.new(Blanks::Base) do
336
+ has_many :images, allow_destroy: true
337
+ end
338
+
339
+ form = form_class.new
340
+ form.images.new(id: 1, url: "https://example.com/1.jpg")
341
+ form.images[0].mark_for_destruction
342
+
343
+ attrs = form.attributes["images_attributes"]
344
+ expect(attrs[0]["_destroy"]).to be true
345
+ end
346
+
347
+ it "handles mixed destroy and update operations" do
348
+ nested_class = Class.new(Blanks::Base) do
349
+ attribute :id, :integer
350
+ attribute :url, :string
351
+ end
352
+ stub_const("ImageForm", nested_class)
353
+
354
+ form_class = Class.new(Blanks::Base) do
355
+ has_many :images, allow_destroy: true
356
+ end
357
+
358
+ form = form_class.new
359
+ form.images.new(id: 1, url: "keep.jpg")
360
+ form.images.new(id: 2, url: "delete.jpg")
361
+ form.images.new(id: 3, url: "update.jpg")
362
+
363
+ form.images_attributes = [
364
+ { id: 1, url: "keep.jpg" },
365
+ { id: 2, _destroy: "1" },
366
+ { id: 3, url: "updated.jpg" },
367
+ { url: "new.jpg" }
368
+ ]
369
+
370
+ expect(form.images.count).to eq(4)
371
+ expect(form.images[0].marked_for_destruction?).to be false
372
+ expect(form.images[1].marked_for_destruction?).to be true
373
+ expect(form.images[2].url).to eq("updated.jpg")
374
+ expect(form.images[3].url).to eq("new.jpg")
375
+ end
376
+ end
377
+
378
+ end
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ RSpec.describe Blanks::Normalization do
6
+ let(:form_class) do
7
+ Class.new(Blanks::Base) do
8
+ attribute :email, :string
9
+ attribute :phone, :string
10
+ attribute :name, :string
11
+ end
12
+ end
13
+
14
+ describe ".normalizes" do
15
+ describe "with proc normalizer" do
16
+ it "applies normalization on assignment" do
17
+ form_class.normalizes :email, with: ->(email) { email.strip.downcase }
18
+ form = form_class.new(email: " TEST@EXAMPLE.COM ")
19
+
20
+ expect(form.email).to eq("test@example.com")
21
+ end
22
+
23
+ it "is idempotent when applied multiple times" do
24
+ form_class.normalizes :email, with: ->(email) { email.strip.downcase }
25
+ form = form_class.new(email: " TEST@EXAMPLE.COM ")
26
+
27
+ form.email = form.email
28
+ form.email = form.email
29
+
30
+ expect(form.email).to eq("test@example.com")
31
+ end
32
+
33
+ it "applies normalization on re-assignment" do
34
+ form_class.normalizes :email, with: ->(email) { email.strip.downcase }
35
+ form = form_class.new(email: "test@example.com")
36
+
37
+ form.email = " UPDATED@EXAMPLE.COM "
38
+
39
+ expect(form.email).to eq("updated@example.com")
40
+ end
41
+ end
42
+
43
+ describe "with symbol normalizer" do
44
+ it "calls the method on the value" do
45
+ form_class.normalizes :email, with: :downcase
46
+ form = form_class.new(email: "TEST@EXAMPLE.COM")
47
+
48
+ expect(form.email).to eq("test@example.com")
49
+ end
50
+ end
51
+
52
+ describe "with multiple attributes" do
53
+ it "applies normalization to all specified attributes" do
54
+ form_class.normalizes :email, :name, with: ->(value) { value.strip.downcase }
55
+ form = form_class.new(email: " TEST@EXAMPLE.COM ", name: " JOHN DOE ")
56
+
57
+ expect(form.email).to eq("test@example.com")
58
+ expect(form.name).to eq("john doe")
59
+ end
60
+ end
61
+
62
+ describe "with apply_to_nil: false" do
63
+ it "does not apply normalization to nil values" do
64
+ form_class.normalizes :email, with: ->(email) { email.strip.downcase }
65
+ form = form_class.new(email: nil)
66
+
67
+ expect(form.email).to be_nil
68
+ end
69
+ end
70
+
71
+ describe "with apply_to_nil: true" do
72
+ it "applies normalization to nil values" do
73
+ form_class.normalizes :email, with: ->(email) { email || "default@example.com" }, apply_to_nil: true
74
+ form = form_class.new(email: nil)
75
+
76
+ expect(form.email).to eq("default@example.com")
77
+ end
78
+ end
79
+
80
+ describe "complex normalization" do
81
+ it "handles chained operations" do
82
+ form_class.normalizes :phone, with: ->(phone) { phone.delete("^0-9").delete_prefix("1") }
83
+ form = form_class.new(phone: "1-555-123-4567")
84
+
85
+ expect(form.phone).to eq("5551234567")
86
+ end
87
+
88
+ it "is idempotent for complex normalizations" do
89
+ form_class.normalizes :phone, with: ->(phone) { phone.delete("^0-9").delete_prefix("1") }
90
+ form = form_class.new(phone: "1-555-123-4567")
91
+
92
+ form.phone = form.phone
93
+ form.phone = form.phone
94
+
95
+ expect(form.phone).to eq("5551234567")
96
+ end
97
+ end
98
+
99
+ describe "integration with assign_attributes" do
100
+ it "applies normalization when using assign_attributes" do
101
+ form_class.normalizes :email, with: ->(email) { email.strip.downcase }
102
+ form = form_class.new
103
+
104
+ form.assign_attributes(email: " TEST@EXAMPLE.COM ")
105
+
106
+ expect(form.email).to eq("test@example.com")
107
+ end
108
+ end
109
+
110
+ describe "integration with from_model" do
111
+ it "applies normalization when loading from model" do
112
+ model = Struct.new(:email).new(" TEST@EXAMPLE.COM ")
113
+ form_class.normalizes :email, with: ->(email) { email.strip.downcase }
114
+ form = form_class.new
115
+
116
+ form.from_model(model)
117
+
118
+ expect(form.email).to eq("test@example.com")
119
+ end
120
+ end
121
+ end
122
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ source "https://rubygems.org"
4
+
5
+ gem "rails", ">= 7.0"
6
+ gem "sqlite3"
7
+ gem "simple_form"
8
+ gem "blanks", path: "../.."
9
+
10
+ gem "puma", "~> 7.1"