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
endOut 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
endRails 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
endFormObjects::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
endAfter 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
endLet'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
endBut 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 }
endNow 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 errorsLet 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
endInstead 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 }
endI 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 }
endAfter 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 }
endI 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 }
endAfter 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_attributesmethod 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)
endWhen 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"}}) # badTo 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


