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
|
@@ -0,0 +1,52 @@
|
|
|
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, default: -> { Time.current }
|
|
25
|
+
|
|
26
|
+
validates :title, presence: true
|
|
27
|
+
validates :content, presence: true
|
|
28
|
+
validates :created_at, presence: true
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
form = PostForm.new
|
|
32
|
+
puts "empty form valid: #{form.valid?}"
|
|
33
|
+
|
|
34
|
+
form = PostForm.new(
|
|
35
|
+
title: "hello world",
|
|
36
|
+
content: "this is content",
|
|
37
|
+
created_at: Time.now,
|
|
38
|
+
cover_photo_attributes: { url: "https://example.com/cover.jpg" },
|
|
39
|
+
images_attributes: [
|
|
40
|
+
{ url: "https://example.com/1.jpg", caption: "first" },
|
|
41
|
+
{ url: "https://example.com/2.jpg", caption: "second" }
|
|
42
|
+
]
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
puts "form with attributes valid: #{form.valid?}"
|
|
46
|
+
puts "title: #{form.title}"
|
|
47
|
+
puts "cover photo url: #{form.cover_photo.url}"
|
|
48
|
+
puts "images count: #{form.images.count}"
|
|
49
|
+
|
|
50
|
+
form = PostForm.new
|
|
51
|
+
form.images.new(url: "https://example.com/new.jpg", caption: "new image")
|
|
52
|
+
puts "form with new image count: #{form.images.count}"
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Blanks
|
|
4
|
+
class AssociationProxy
|
|
5
|
+
include Enumerable
|
|
6
|
+
|
|
7
|
+
def initialize(class_name)
|
|
8
|
+
@class_name = class_name
|
|
9
|
+
@records = []
|
|
10
|
+
@records_by_id = {}
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def new(attributes = {})
|
|
14
|
+
form_class = @class_name.constantize
|
|
15
|
+
instance = form_class.new(attributes)
|
|
16
|
+
push(instance)
|
|
17
|
+
instance
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def build(attributes = {})
|
|
21
|
+
new(attributes)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def find_by_id(id)
|
|
25
|
+
@records_by_id[id.to_s]
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def find_by(attribute, value)
|
|
29
|
+
return @records_by_id[value.to_s] if attribute.to_s == "id" && @records_by_id.key?(value.to_s)
|
|
30
|
+
|
|
31
|
+
@records.find do |record|
|
|
32
|
+
record.respond_to?(attribute) && record.public_send(attribute).to_s == value.to_s
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def push(record)
|
|
37
|
+
@records << record
|
|
38
|
+
if record.respond_to?(:id) && record.id.present?
|
|
39
|
+
@records_by_id[record.id.to_s] = record
|
|
40
|
+
end
|
|
41
|
+
self
|
|
42
|
+
end
|
|
43
|
+
alias_method :<<, :push
|
|
44
|
+
|
|
45
|
+
def each(&block)
|
|
46
|
+
@records.each(&block)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def size
|
|
50
|
+
@records.size
|
|
51
|
+
end
|
|
52
|
+
alias_method :length, :size
|
|
53
|
+
alias_method :count, :size
|
|
54
|
+
|
|
55
|
+
def empty?
|
|
56
|
+
@records.empty?
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def any?
|
|
60
|
+
@records.any?
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def [](index)
|
|
64
|
+
@records[index]
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def clear
|
|
68
|
+
@records.clear
|
|
69
|
+
@records_by_id.clear
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def to_a
|
|
73
|
+
@records
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def to_ary
|
|
77
|
+
@records
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def persisted?
|
|
81
|
+
false
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def valid?
|
|
85
|
+
@records.all?(&:valid?)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def errors
|
|
89
|
+
@records.flat_map(&:errors)
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Blanks
|
|
4
|
+
module Associations
|
|
5
|
+
extend ActiveSupport::Concern
|
|
6
|
+
|
|
7
|
+
included do
|
|
8
|
+
class_attribute :associations, default: {}
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
class_methods do
|
|
12
|
+
def has_one(name, class_name: nil, primary_key: :id, **options)
|
|
13
|
+
association_class_name = class_name || "#{name.to_s.camelize}Form"
|
|
14
|
+
|
|
15
|
+
self.associations = associations.merge(
|
|
16
|
+
name.to_s => { type: :has_one, class_name: association_class_name, primary_key: primary_key.to_s }
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
attr_reader name
|
|
20
|
+
|
|
21
|
+
define_method("#{name}=") do |value|
|
|
22
|
+
instance_variable_set("@#{name}", value)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
define_method("build_#{name}") do |attributes = {}|
|
|
26
|
+
form_class = association_class_name.constantize
|
|
27
|
+
instance = form_class.new(attributes)
|
|
28
|
+
instance_variable_set("@#{name}", instance)
|
|
29
|
+
instance
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
accepts_nested_attributes_for(name, primary_key: primary_key, **options)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def has_many(name, class_name: nil, primary_key: :id, **options)
|
|
36
|
+
association_class_name = class_name || "#{name.to_s.singularize.camelize}Form"
|
|
37
|
+
|
|
38
|
+
self.associations = associations.merge(
|
|
39
|
+
name.to_s => { type: :has_many, class_name: association_class_name, primary_key: primary_key.to_s }
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
define_method(name) do
|
|
43
|
+
ivar = "@#{name}"
|
|
44
|
+
unless instance_variable_defined?(ivar)
|
|
45
|
+
instance_variable_set(ivar, AssociationProxy.new(association_class_name))
|
|
46
|
+
end
|
|
47
|
+
instance_variable_get(ivar)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
define_method("#{name}=") do |value|
|
|
51
|
+
instance_variable_set("@#{name}", value)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
accepts_nested_attributes_for(name, primary_key: primary_key, **options)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
data/lib/blanks/base.rb
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_model"
|
|
4
|
+
|
|
5
|
+
module Blanks
|
|
6
|
+
class Base
|
|
7
|
+
include ActiveModel::Model
|
|
8
|
+
include ActiveModel::Attributes
|
|
9
|
+
include ActiveModel::Validations
|
|
10
|
+
include ActiveModel::Dirty
|
|
11
|
+
include ActiveModel::Callbacks
|
|
12
|
+
|
|
13
|
+
include Blanks::ModelNaming
|
|
14
|
+
include Blanks::Normalization
|
|
15
|
+
include Blanks::Associations
|
|
16
|
+
include Blanks::NestedAttributes
|
|
17
|
+
|
|
18
|
+
define_model_callbacks :validation
|
|
19
|
+
|
|
20
|
+
def self.inherit_attributes_from(model_class, only: nil, except: nil)
|
|
21
|
+
raise ArgumentError, "cannot specify both :only and :except" if only && except
|
|
22
|
+
|
|
23
|
+
attribute_names = if model_class.respond_to?(:attribute_types)
|
|
24
|
+
model_class.attribute_types.keys
|
|
25
|
+
elsif model_class.respond_to?(:columns)
|
|
26
|
+
model_class.columns.map(&:name)
|
|
27
|
+
else
|
|
28
|
+
raise ArgumentError, "#{model_class} does not respond to :attribute_types or :columns"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
attribute_names = Array(only).map(&:to_s) if only
|
|
32
|
+
attribute_names -= Array(except).map(&:to_s) if except
|
|
33
|
+
|
|
34
|
+
attribute_names.each do |attr_name|
|
|
35
|
+
next if attribute_types.key?(attr_name)
|
|
36
|
+
|
|
37
|
+
attr_type = if model_class.respond_to?(:attribute_types)
|
|
38
|
+
model_class.attribute_types[attr_name]
|
|
39
|
+
elsif model_class.respond_to?(:columns)
|
|
40
|
+
column = model_class.columns.find { |c| c.name == attr_name }
|
|
41
|
+
column&.type
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
attribute attr_name.to_sym, attr_type&.type || :value
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def self.inherit_validations_from(model_class, only: nil, except: nil)
|
|
49
|
+
raise ArgumentError, "cannot specify both :only and :except" if only && except
|
|
50
|
+
|
|
51
|
+
model_class.validators.each do |validator|
|
|
52
|
+
next if skip_validator?(validator)
|
|
53
|
+
|
|
54
|
+
attrs = validator.attributes.dup
|
|
55
|
+
attrs = attrs & Array(only).map(&:to_sym) if only
|
|
56
|
+
attrs = attrs - Array(except).map(&:to_sym) if except
|
|
57
|
+
next if attrs.empty?
|
|
58
|
+
|
|
59
|
+
options = validator.options.except(:class)
|
|
60
|
+
kind = validator.kind
|
|
61
|
+
|
|
62
|
+
if options.empty?
|
|
63
|
+
validates(*attrs, kind => true)
|
|
64
|
+
else
|
|
65
|
+
validates(*attrs, kind => options)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def self.skip_validator?(validator)
|
|
71
|
+
if defined?(ActiveRecord::Validations::AssociatedValidator)
|
|
72
|
+
return true if validator.is_a?(ActiveRecord::Validations::AssociatedValidator)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
opts = validator.options
|
|
76
|
+
[:if, :unless].any? { |key| opts[key].is_a?(Proc) }
|
|
77
|
+
end
|
|
78
|
+
private_class_method :skip_validator?
|
|
79
|
+
|
|
80
|
+
def self.from_model(model)
|
|
81
|
+
instance = new
|
|
82
|
+
instance.from_model(model)
|
|
83
|
+
instance
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def initialize(attributes = {})
|
|
87
|
+
super()
|
|
88
|
+
@marked_for_destruction = false
|
|
89
|
+
assign_attributes(attributes) if attributes.present?
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def assign_attributes(new_attributes)
|
|
93
|
+
return if new_attributes.blank?
|
|
94
|
+
|
|
95
|
+
attrs = if new_attributes.respond_to?(:to_unsafe_h)
|
|
96
|
+
new_attributes.to_unsafe_h.stringify_keys
|
|
97
|
+
elsif new_attributes.respond_to?(:to_h)
|
|
98
|
+
new_attributes.to_h.stringify_keys
|
|
99
|
+
else
|
|
100
|
+
new_attributes.stringify_keys
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
attrs.each do |key, value|
|
|
104
|
+
if key.end_with?("_attributes")
|
|
105
|
+
association_name = key.delete_suffix("_attributes")
|
|
106
|
+
send("#{association_name}_attributes=", value)
|
|
107
|
+
elsif key == "_destroy"
|
|
108
|
+
self._destroy = value
|
|
109
|
+
else
|
|
110
|
+
public_send("#{key}=", value)
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
changes_applied
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def from_model(model)
|
|
118
|
+
return self if model.nil?
|
|
119
|
+
|
|
120
|
+
self.class.attribute_names.each do |attr_name|
|
|
121
|
+
if model.respond_to?(attr_name)
|
|
122
|
+
public_send("#{attr_name}=", model.public_send(attr_name))
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
self.class.associations.each do |name, association|
|
|
127
|
+
next unless model.respond_to?(name)
|
|
128
|
+
|
|
129
|
+
associated_value = model.public_send(name)
|
|
130
|
+
next if associated_value.nil?
|
|
131
|
+
|
|
132
|
+
case association[:type]
|
|
133
|
+
when :has_one
|
|
134
|
+
form_instance = association[:class_name].constantize.new
|
|
135
|
+
form_instance.from_model(associated_value)
|
|
136
|
+
public_send("#{name}=", form_instance)
|
|
137
|
+
when :has_many
|
|
138
|
+
associated_value.each do |record|
|
|
139
|
+
form_instance = association[:class_name].constantize.new
|
|
140
|
+
form_instance.from_model(record)
|
|
141
|
+
public_send(name).push(form_instance)
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
self
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def persisted?
|
|
150
|
+
respond_to?(:id) && id.present?
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def to_key
|
|
154
|
+
persisted? ? [id] : nil
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def to_param
|
|
158
|
+
persisted? ? id.to_s : nil
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def to_model
|
|
162
|
+
self
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def marked_for_destruction?
|
|
166
|
+
@marked_for_destruction
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def mark_for_destruction
|
|
170
|
+
@marked_for_destruction = true
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def _destroy
|
|
174
|
+
@marked_for_destruction
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def _destroy=(value)
|
|
178
|
+
@marked_for_destruction = ActiveModel::Type::Boolean.new.cast(value)
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def valid?(context = nil)
|
|
182
|
+
run_callbacks :validation do
|
|
183
|
+
super(context) && nested_forms_valid?
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def model_attributes
|
|
188
|
+
attribute_names.each_with_object({}) do |name, hash|
|
|
189
|
+
hash[name] = public_send(name)
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def attributes
|
|
194
|
+
attrs = model_attributes.dup
|
|
195
|
+
|
|
196
|
+
self.class.associations.each do |name, association|
|
|
197
|
+
nested_form = public_send(name)
|
|
198
|
+
next if nested_form.nil?
|
|
199
|
+
|
|
200
|
+
case association[:type]
|
|
201
|
+
when :has_one
|
|
202
|
+
nested_attrs = nested_form.attributes
|
|
203
|
+
nested_attrs["_destroy"] = true if nested_form.marked_for_destruction?
|
|
204
|
+
attrs["#{name}_attributes"] = nested_attrs
|
|
205
|
+
when :has_many
|
|
206
|
+
attrs["#{name}_attributes"] = nested_form.map do |form|
|
|
207
|
+
form_attrs = form.attributes
|
|
208
|
+
form_attrs["_destroy"] = true if form.marked_for_destruction?
|
|
209
|
+
form_attrs
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
attrs
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def assignable_attributes(exclude: [:id])
|
|
218
|
+
excluded_keys = Array(exclude).map(&:to_s)
|
|
219
|
+
attributes.except(*excluded_keys)
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
private
|
|
223
|
+
|
|
224
|
+
def nested_forms_valid?
|
|
225
|
+
self.class.associations.all? do |name, association|
|
|
226
|
+
nested_form = public_send(name)
|
|
227
|
+
next true if nested_form.nil?
|
|
228
|
+
|
|
229
|
+
case association[:type]
|
|
230
|
+
when :has_one
|
|
231
|
+
if nested_form.valid?
|
|
232
|
+
true
|
|
233
|
+
else
|
|
234
|
+
copy_nested_errors(name, nested_form)
|
|
235
|
+
false
|
|
236
|
+
end
|
|
237
|
+
when :has_many
|
|
238
|
+
if nested_form.valid?
|
|
239
|
+
true
|
|
240
|
+
else
|
|
241
|
+
nested_form.each_with_index do |form, index|
|
|
242
|
+
copy_nested_errors("#{name}[#{index}]", form) unless form.valid?
|
|
243
|
+
end
|
|
244
|
+
false
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def copy_nested_errors(association_name, nested_form)
|
|
251
|
+
nested_form.errors.each do |error|
|
|
252
|
+
errors.add("#{association_name}.#{error.attribute}", error.message)
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Blanks
|
|
4
|
+
module ModelNaming
|
|
5
|
+
extend ActiveSupport::Concern
|
|
6
|
+
|
|
7
|
+
included do
|
|
8
|
+
class_attribute :_model_name_override
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
class_methods do
|
|
12
|
+
def model_name
|
|
13
|
+
return _model_name_override if _model_name_override
|
|
14
|
+
|
|
15
|
+
name_without_form = self.name.sub(/Form$/, '')
|
|
16
|
+
ActiveModel::Name.new(self, nil, name_without_form)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def model_name_for(name)
|
|
20
|
+
self._model_name_override = ActiveModel::Name.new(self, nil, name.to_s.camelize)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Blanks
|
|
4
|
+
module NestedAttributes
|
|
5
|
+
extend ActiveSupport::Concern
|
|
6
|
+
|
|
7
|
+
included do
|
|
8
|
+
class_attribute :nested_attributes_options, default: {}
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
class_methods do
|
|
12
|
+
def accepts_nested_attributes_for(*attr_names)
|
|
13
|
+
options = attr_names.extract_options!
|
|
14
|
+
|
|
15
|
+
attr_names.each do |association_name|
|
|
16
|
+
self.nested_attributes_options = nested_attributes_options.merge(
|
|
17
|
+
association_name.to_s => options
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
define_nested_attributes_method(association_name)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def define_nested_attributes_method(association_name)
|
|
27
|
+
define_method("#{association_name}_attributes=") do |attributes|
|
|
28
|
+
association = self.class.associations[association_name.to_s]
|
|
29
|
+
|
|
30
|
+
unless association
|
|
31
|
+
raise ArgumentError, "No association found for name '#{association_name}'"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
case association[:type]
|
|
35
|
+
when :has_one
|
|
36
|
+
assign_nested_attributes_for_one_to_one_association(association_name, attributes)
|
|
37
|
+
when :has_many
|
|
38
|
+
assign_nested_attributes_for_collection_association(association_name, attributes)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def assign_nested_attributes_for_one_to_one_association(association_name, attributes)
|
|
47
|
+
return if attributes.blank?
|
|
48
|
+
|
|
49
|
+
association = self.class.associations[association_name.to_s]
|
|
50
|
+
options = self.class.nested_attributes_options[association_name.to_s] || {}
|
|
51
|
+
form_class = association[:class_name].constantize
|
|
52
|
+
attrs_hash = attributes.stringify_keys
|
|
53
|
+
|
|
54
|
+
if has_destroy_flag?(attrs_hash) && options[:allow_destroy]
|
|
55
|
+
instance = public_send(association_name)
|
|
56
|
+
instance&.mark_for_destruction
|
|
57
|
+
return
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
instance = public_send(association_name)
|
|
61
|
+
attrs_without_destroy = attrs_hash.except("_destroy")
|
|
62
|
+
if instance
|
|
63
|
+
instance.assign_attributes(attrs_without_destroy)
|
|
64
|
+
else
|
|
65
|
+
instance = form_class.new(attrs_without_destroy)
|
|
66
|
+
public_send("#{association_name}=", instance)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def assign_nested_attributes_for_collection_association(association_name, attributes)
|
|
71
|
+
return if attributes.blank?
|
|
72
|
+
|
|
73
|
+
attributes_collection = attributes.is_a?(Hash) ? attributes.values : attributes
|
|
74
|
+
association = self.class.associations[association_name.to_s]
|
|
75
|
+
options = self.class.nested_attributes_options[association_name.to_s] || {}
|
|
76
|
+
form_class = association[:class_name].constantize
|
|
77
|
+
collection = public_send(association_name)
|
|
78
|
+
primary_key = (options[:primary_key] || association[:primary_key] || "id").to_s
|
|
79
|
+
|
|
80
|
+
attributes_collection.each do |attrs|
|
|
81
|
+
next if call_reject_if(association_name, attrs)
|
|
82
|
+
|
|
83
|
+
attrs_hash = attrs.is_a?(Hash) ? attrs.stringify_keys : attrs
|
|
84
|
+
|
|
85
|
+
if has_destroy_flag?(attrs_hash)
|
|
86
|
+
if options[:allow_destroy] && attrs_hash[primary_key].present?
|
|
87
|
+
existing = collection.find_by(primary_key, attrs_hash[primary_key])
|
|
88
|
+
if existing
|
|
89
|
+
existing.mark_for_destruction
|
|
90
|
+
else
|
|
91
|
+
new_form = collection.new(attrs_hash.except("_destroy"))
|
|
92
|
+
new_form.mark_for_destruction
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
next
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
if attrs_hash[primary_key].present?
|
|
99
|
+
existing = collection.find_by(primary_key, attrs_hash[primary_key])
|
|
100
|
+
if existing
|
|
101
|
+
existing.assign_attributes(attrs_hash.except(primary_key, "_destroy"))
|
|
102
|
+
else
|
|
103
|
+
collection.new(attrs_hash.except("_destroy"))
|
|
104
|
+
end
|
|
105
|
+
else
|
|
106
|
+
collection.new(attrs_hash.except("_destroy"))
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def call_reject_if(association_name, attributes)
|
|
112
|
+
options = self.class.nested_attributes_options[association_name.to_s]
|
|
113
|
+
return false unless options
|
|
114
|
+
|
|
115
|
+
reject_if = options[:reject_if]
|
|
116
|
+
return false unless reject_if
|
|
117
|
+
|
|
118
|
+
if reject_if.is_a?(Symbol)
|
|
119
|
+
method(reject_if).call(attributes)
|
|
120
|
+
else
|
|
121
|
+
reject_if.call(attributes)
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def has_destroy_flag?(attributes)
|
|
126
|
+
attrs = attributes.stringify_keys
|
|
127
|
+
value = attrs["_destroy"]
|
|
128
|
+
ActiveModel::Type::Boolean.new.cast(value)
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Blanks
|
|
4
|
+
module Normalization
|
|
5
|
+
extend ActiveSupport::Concern
|
|
6
|
+
|
|
7
|
+
included do
|
|
8
|
+
class_attribute :_normalizations, instance_writer: false, default: {}
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
class_methods do
|
|
12
|
+
def normalizes(*names, with:, apply_to_nil: false)
|
|
13
|
+
names.each do |name|
|
|
14
|
+
_normalizations[name.to_s] = { normalizer: with, apply_to_nil: apply_to_nil }
|
|
15
|
+
|
|
16
|
+
define_method("#{name}=") do |value|
|
|
17
|
+
normalized_value = self.class.normalize_value_for(name, value)
|
|
18
|
+
super(normalized_value)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def normalize_value_for(name, value)
|
|
24
|
+
normalization = _normalizations[name.to_s]
|
|
25
|
+
return value unless normalization
|
|
26
|
+
|
|
27
|
+
return value if value.nil? && !normalization[:apply_to_nil]
|
|
28
|
+
|
|
29
|
+
normalizer = normalization[:normalizer]
|
|
30
|
+
if normalizer.respond_to?(:call)
|
|
31
|
+
normalizer.call(value)
|
|
32
|
+
else
|
|
33
|
+
value.public_send(normalizer)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
data/lib/blanks.rb
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support/concern"
|
|
4
|
+
require "active_support/core_ext/string/inflections"
|
|
5
|
+
require "active_support/core_ext/array/extract_options"
|
|
6
|
+
require "active_support/core_ext/object/blank"
|
|
7
|
+
|
|
8
|
+
require "blanks/version"
|
|
9
|
+
require "blanks/association_proxy"
|
|
10
|
+
require "blanks/model_naming"
|
|
11
|
+
require "blanks/normalization"
|
|
12
|
+
require "blanks/associations"
|
|
13
|
+
require "blanks/nested_attributes"
|
|
14
|
+
require "blanks/base"
|
|
15
|
+
|
|
16
|
+
module Blanks
|
|
17
|
+
class Error < StandardError; end
|
|
18
|
+
end
|