FormObjects gives you a easy way of building complex and nested form objects.
Add this line to your application's Gemfile:
gem 'form_objects'
And then execute:
$ bundle
Or install it yourself as:
$ gem install form_objects
In this micro-library you will not find any magic. Explicit is better than implicit. Simple is better than complex.
At the beginning of the life of your application most of the objects is exactly the same as the form. User include first_name
and last_name
.
Only first_name
is required.
class User
validates :first_name, :presence => true
end
# controller
def new
@user = User.new
end
<%= form_for @user do |f| %>
<%= f.label :first_name %>:
<%= f.text_field :first_name %><br />
<%= f.label :last_name %>:
<%= f.text_field :last_name %><br />
<%= f.submit %>
<% end %>
How the same can be achieved using FormObjects
?
class UserForm < FormObjects::Base
field :first_name, String
field :last_name, String
validates :first_name, presence: true
end
Out new UserForm
class does not know nothing about user. Because there is no connection to database.
That is why you need to explicitly defined each field. First argument is name of attribute and second argument is type
of this attribute. #field
method is just alias for attribute
method from virtus.
On FormObjects
you can use the same validations like in ActiveRecord::Base
object. So here there is no change.
# controller
def new
@user_form = UserForm.new
end
<%= form_for @user_form do |f| %>
<%= f.label :first_name %>:
<%= f.text_field :first_name %><br />
<%= f.label :last_name %>:
<%= f.text_field :last_name %><br />
<%= f.submit %>
<% end %>
Ok, now we can just save user to our storage. Do you you think about @user_form.save
?
Keep your objects simple. Form object is responsible for maintaining and validating data. Things like storing these data leave other objects. So what now?
You can create UserCreator
.
class UserCreator
def initialize(attributes)
@attributes = attributes
end
def create
User.create(@attributes)
end
end
Rails form generator will generate form with attributes scoped in user_form
. So when you submit your form params
will look like this:
{
:user_form => {
:first_name => "First name",
:last_name => "Last name"
}
}
You can change it by adding FormObjects::Naming
to your form class definition.
class UserForm < FormObjects::Base
include FormObjects::Naming
field :first_name, String
field :last_name, String
validates :first_name, presence: true
end
FormObjects::Naming
will generate .model_name
method. This method will return ActiveModel::Name
object who will pretend that the model does not include Form
in the name.
You can of course define your own .model_name
method.
class UserForm < FormObjects::Base
field :first_name, String
field :last_name, String
validates :first_name, presence: true
def self.model_name
ActiveModel::Name.new(self, nil, "User")
end
end
After this change params will look like this:
{
:user => {
:first_name => "First name",
:last_name => "Last name"
}
}
So we can implement create
controller action.
# controller
def create
@user_form = UserForm.new(params[:user])
if @user_form.valid?
UserCreator.new(@user_form.serialized_attributes).create
else
render :new
end
end
Let's do something standard. Add term and condition checkbox. In rails way you will add term
attribute to your User
model, didn't you?
Do not you think it's a little weird? I think so. Let's do this in UserForm
.
class UserForm < FormObjects::Base
include FormObjects::Naming
field :first_name, String
field :last_name, String
field :terms, Boolean
validates :first_name, presence: true
validates :terms, acceptance: true
end
But there is a problem with terms
validation.
UserForm.new(:terms => "1")
# => #<UserForm:0x00000004bbd2e0 @first_name=nil, @last_name=nil, @terms=true>
Virtus library will transform terms
value into boolean. But by default acceptance
will look for "1"
value.
form = UserForm.new(:terms => "1")
# => #<UserForm:0x00000004be2400 @first_name=nil, @last_name=nil, @terms=true>
form.valid?
# => false
form.errors.full_messages
# => ["First name can't be blank", "Terms must be accepted"]
Solution? You can change terms
field into String
type. But this is strange. I recommended clarify validation.
class UserForm < FormObjects::Base
include FormObjects::Naming
field :first_name, String
field :last_name, String
field :terms, Boolean
validates :first_name, presence: true
validates :terms, acceptance: { accept: true }
end
Now everything should works just fine. No magic.
form = UserForm.new(:terms => "1")
# => #<UserForm:0x00000004de7f20 @terms=true, @first_name=nil, @last_name=nil>
form.valid?
# => false
form.errors.full_messages
# => ["First name can't be blank"]
# No terms errors
Let add another form to our UserForm
. User during registration should give the address. Lets create LocationForm
.
class LocationForm < FormObjects::Form
field :address, String
validates :address, presence: true
end
Instead of field
method we need to use nested_form
.
class UserForm < FormObjects::Base
include FormObjects::Naming
field :first_name, String
field :last_name, String
field :terms, Boolean
nested_form :address, LocationForm
validates :first_name, presence: true
validates :terms, acceptance: { accept: true }
end
I will switch now to simple_form
. But you can use original form_for
form rails.
<%= simple_form_for @user_form, :url => homes_path do |f| %>
<%= f.input :first_name %>
<%= f.input :last_name %>
<%= f.input :terms, :as => :boolean %>
<%= f.simple_fields_for :address do |a| %>
<%= a.input :address %>
<% end %>
<%= f.button :submit %>
<% end %>
You will notice one problem. That address
field is not rendered. The reason is that LocationForm
is not initialized.
You can use Virtus default
attribute to accomplish this.
class UserForm < FormObjects::Base
include FormObjects::Naming
field :first_name, String
field :last_name, String
field :terms, Boolean
nested_form :address, LocationForm, default: proc { LocationForm.new }
validates :first_name, presence: true
validates :terms, acceptance: { accept: true }
end
After this change location form should be rendered. When you submit this form params will looks like:
{
:user => {
:first_name => "FirstName",
:last_name => "LastName",
:terms => "1",
:address_attributes => {
:address => "Street"
}
}
}
When you pass these params
to form object you can use serialized_attriubtes
method. It will return developer-friendly hash with values.
UserForm.new(params).serialized_attributes
# => {:first_name=>"FirstName", :last_name=>"LastName", :terms=>true, :address=>{:address=>"Street"}}
You can use this Hash
inside your classes, services etc.
What we should do when we need more than 1 address? We can use Array
from Virtus.
class UserForm < FormObjects::Base
include FormObjects::Naming
field :first_name, String
field :last_name, String
field :terms, Boolean
nested_form :addresses, Array[LocationForm]
validates :first_name, presence: true
validates :terms, acceptance: { accept: true }
end
I changed address
to addresses
and instead of simple LocationForm
we will use Array[LocationForm]
. But once again problem with default values.
You can use default
attribute from Virtus.
Array.new(2, LocationForm.new)
# => [#<LocationForm:0x00000004ffe0e8 @address=nil>, #<LocationForm:0x00000004ffe0e8 @address=nil>]
So we can apply this to our form.
class UserForm < FormObjects::Base
include FormObjects::Naming
NUMBER_OF_LOCATION_FORMS = 2
field :first_name, String
field :last_name, String
field :terms, Boolean
nested_form :addresses, Array[LocationForm], default: proc { Array.new(NUMBER_OF_LOCATION_FORMS, LocationForm.new) }
validates :first_name, presence: true
validates :terms, acceptance: { accept: true }
end
After this your form will be renderer. But almost for sure you will get exception:
undefined method `0=' for #<LocationForm:0x007fdbc002bb80>
Now our params looks like this:
{
:user =>{
:first_name => "FirstName",
:last_name" => "LastName",
:terms => "1",
:addresses_attributes => {
"0" => {:address=>"Street1"},
"1" => {"address=>"Street2"}
}
}
}
From now we need to use FormObjects::ParamsConverter
. Because Virtus models will not accept rails magic.
FormObjects::ParamsConverter.new(params).params
{
:user => {
:first_name => "FirstName",
:last_name => "LastName",
:terms => "1",
:addresses_attributes=> [
{:address => "Street1"},
{:address => "Street2"}
]
}
}
FormObjects::ParamsConverter
convert Hash
created by rails to friendly Array. You can use this Hash to initialize your form.
UserForm.new(converted_params[:user])
private
def converted_params
FormObjects::ParamsConverter.new(params).params
end
- FormObjects use Virtus for Property API
- Nested forms objects are validate together with parent form, errors are being push to parent.
#serialized_attributes
method returns attributes hash
class AddressForm < FormObjects::Base
field :street, String
field :city, String
validates :street, presence: true
end
class PersonalInfoForm < FormObjects::Base
field :first_name, String
field :last_name, String
validates :first_name, presence: true
end
class UserForm < FormObjects::Base
field :email, String
nested_form :addresses, Array[AddressForm]
nested_form :personal_info, PersonalInfoForm
end
service = UserUpdater.new
form = UserForm.new
form.update({
email: 'john.doe@example.com',
personal_info_attributes: {first_name: 'John'},
addresses_attributes: [{street: 'Golden Street'}]
})
if form.valid?
service.update(form.serialized_attributes)
end
When you use HTTP there is no ensure that parameters that you receive will be ordered. That why rails wrap Arrays inside Hash.
["one", "two", "three"] => {"0" => "one", "1" => "two", "2" => "three"}
But form object expects that nested params will be kind of Array
class UserForm < FormObjects::Base
nested_form :addresses, Array[AddressForm]
end
UserForm.new(:addresses_attributes => [{:name => "Name"}]) # good
# instead of
UserForm.new(:addresses_attributes => {"0" => {:name => "Name"}}) # bad
To avoid these problems you can use FormObjects::ParamsConverter
.
params = { "event_attributes" => {"0" => "one", "1" => "two", "2" => "three"} }
converter = FormObjects::ParamsConverter.new(params)
converter.params #=> { "event_attributes" => ["one", "two", "three"] }
Multi-parameter dates can be easily converted to friendly form.
params = { "event" => { "date(1i)" => "2014", "date(2i)" => "12", "date(3i)" => "16", "date(4i)" => "12", "date(5i)" => "30", "date(6i)" => "45" } }
converter = FormObjects::ParamsConverter.new(params)
converter.params #=> { "event" => { "date" => "2014.12.16 12:30:45" } }
- Fork it
- Create your feature branch (
git checkout -b my-new-feature
) - Commit your changes (
git commit -am 'Add some feature'
) - Push to the branch (
git push origin my-new-feature
) - Create new Pull Request