FormObj

Form Object allows to describe complicated data structure (nesting, arrays) and use it with Rails-cmpatible form builders. Form Object can serialize and deserialize itself to/from model and hash.

Installation

Add this line to your application's Gemfile:

gem 'form_obj'

And then execute:

$ bundle

Or install it yourself as:

$ gem install form_obj

Usage

WARNING!!! The gem is still under development. Expecting braking changes.

Form Object FormObj::Form is inherited from TreeStruct (https://github.com/akoltun/tree_struct). So on top of all TreeStruct functionality FormObj::Obj adds update_attributes method for mass update of attributes (similar to ActuveRecord) and syntax sugar to easily use ActiveModel::Validations and ActiveModel::Errors with TreeStruct.

Mappable module included in FormObj::Form oblect allows to map form object to a model, load attributes from and attributes to it, represent form object as model hash (similar to to_hash method but includes only attributes mapped to the model and with model attributes names) and copy errors from the model(s) into a from object.

Table of Contents

  1. Definition
    1. Nested Form Objects
    2. Array of Form Objects
  2. Update Attributes
    1. Nested Form Objects
    2. Array of Form Objects
  3. Serialize to Hash
    1. Nested Form Objects
    2. Array of Form Objects
  4. Map Form Object to Models
    1. Multiple Models Example
    2. Skip Attribute Mapping
      1. Map Nested Form Object Attribute to Parent Level Model Attribute
    3. Map Nested Form Object to A Hash Model
  5. Load Form Object from Models
  6. Save Form Object to Models
    1. Array of Form Objects and Models
  7. Serialize Form Object to Model Hash
  8. Validation and Coercion
  9. Copy Model Validation Errors into Form Object
  10. Rails Example
  11. Reference Guide

1. Definition

Inherit your class from FormObj::Form and define its attributes.

class SimpleForm < FormObj::Form
  attribute :name
  attribute :year
end

Use it in form builder.

<%= form_for(@simple_form) do |f| %>
  <%= f.label :name %>
  <%= f.text_field :name %>

  <%= f.label :year %>
  <%= f.text_field :year %>
<% end %>

1.1. Nested Form Objects

Use blocks to define nested forms.

class NestedForm < FormObj::Form
  attribute :name
  attribute :year
  attribute :car do
    attribute :model
    attribute :driver
    attribute :engine do
      attribute :power
      attribute :volume
    end
  end
end

Or explicitly define nested form class.

class EngineForm < FormObj::Form
  attribute :power
  attribute :volume
end
class CarForm < FormObj::Form
  attribute :model
  attribute :driver
  attribute :engine, class: EngineForm
end
class NestedForm < FormObj::Form
  attribute :name
  attribute :year
  attribute :car, class: CarForm
end

Use nested forms in form builder.

<%= form_for(@nested_form) do |f| %>
  <%= f.label :name %>
  <%= f.text_field :name %>

  <%= f.label :year %>
  <%= f.text_field :year %>

  <%= f.fields_for(:car) do |fc| %>
    <%= fc.label :model %>
    <%= fc.text_field :model %>

    <%= fc.label :driver %>
    <%= fc.text_field :driver %>

    <%= fc.field_for(:engine) do |fce| %>
      <%= fce.label :power %>
      <%= fce.text_field :power %>

      <%= fce.label :volume %>
      <%= fce.text_field :volume %>
    <% end %>
  <% end %>
<% end %>

1.2. Array of Form Objects

Specify attribute parameter array: true in order to define an array of form objects

class ArrayForm < FormObj::Form
  attribute :name
  attribute :year
  attribute :cars, array: true do
    attribute :model
    attribute :driver
    attribute :engine do
      attribute :power
      attribute :volume
    end
  end
end

or

class EngineForm < FormObj::Form
  attribute :power
  attribute :volume
end
class CarForm < FormObj::Form
  attribute :model
  attribute :driver
  attribute :engine, class: EngineForm
end
class ArrayForm < FormObj::Form
  attribute :name
  attribute :year
  attribute :cars, array: true, class: CarForm
end

Add new elements in the array by using method :create on which adds a new it.

array_form = ArrayForm.new
array_form.size                 # => 0
array_form.cars.create
array_form.size                 # => 1

2. Update Attributes

Update form object attributes with the parameter hash received from the browser. Method update_attributes(new_attrs_hash) returns self so one can chain calls.

simple_form = SimpleForm.new
simple_form.name = 'Ferrari'
simple_form.year = 1950
simple_form.update_attributes(
                                name: 'McLaren',
                                year: 1966
                             )
simple_form.name      # => "McLaren"                             
simple_form.year      # => 1966                             

2.1. Nested Form Objects

nested_form = NestedForm.new
nested_form.name = 'Ferrari'
nested_form.year = 1950
nested_form.car.model = '340 F1'
nested_form.car.driver = 'Ascari'
nested_form.car.engine.power = 335
nested_form.car.engine.volume = 4.1
nested_form.update_attributes(
                                name: 'McLaren',
                                year: 1966,
                                car: {
                                  model: 'M2B',
                                  driver: 'Bruce McLaren',
                                  engine: {
                                    power: 300,
                                    volume: 3.0
                                  }
                                }
                             )
nested_form.name                  # => "McLaren"
nested_form.year                  # => 1966
nested_form.car.model             # => "M2B"
nested_form.car.driver            # => "Bruce McLaren"
nested_form.car.engine.power      # => 300
nested_form.car.engine.volume     # => 3.0

2.2. Array of Form Objects

Updating array of form objects will compare the existing array and the new one. New array elements will be added, existing array elements will be updated, absent array elements will be deleted (deleting behaviour is the subject of changes in future releases - only elements with flag _destroy == true will be deleted).

In order to compare old and new array its elements have to be identified via the primary key. Primary key can be specified either on the attribute level or on the form level. If it is not specified the :id field is supposed to be a primary key.

class ArrayForm < FormObj::Form
  attribute :name
  attribute :year
  attribute :cars, array: true do
    attribute :model, primary_key: true     # <- primary key is specified on attribute level
    attribute :driver
    attribute :engine do
      attribute :power
      attribute :volume
    end
  end
end
class ArrayForm < FormObj::Form
  attribute :name
  attribute :year
  attribute :cars, array: true, primary_key: :model do     # <- primary key is specified on form level
    attribute :model
    attribute :driver
    attribute :engine do
      attribute :power
      attribute :volume
    end
  end
end
array_form = ArrayForm.new
array_form.name = 'Ferrari'
array_form.year = 1950

car1 = array_form.cars.create
car1.model = '340 F1'
car1.driver = 'Ascari'
car1.engine.power = 335
car1.engine.volume = 4.1

car2 = array_form.cars.create
car2.model = 'M2B'
car2.driver = 'Villoresi'
car2.engine.power = 300
car2.engine.volume = 3.3

array_form.update_attributes(
                              name: 'McLaren',
                              year: 1966,
                              cars: [
                                  {
                                      model: 'M2B',
                                      driver: 'Bruce McLaren',
                                      engine: {
                                          volume: 3.0
                                      }
                                  }, {
                                      model: 'M7A',
                                      driver: 'Denis Hulme',
                                      engine: {
                                          power: 415,
                                      }
                                  }
                              ],
                            )

array_form.name                     # => "McLaren"
array_form.year                     # => 1966

array_form.cars[0].model            # => "M2B"
array_form.cars[0].driver           # => "Bruce McLaren"
array_form.cars[0].engine.power     # => 300    - this value was not updated in update_attributes
array_form.cars[0].engine.volume    # => 3.0

array_form.cars[1].model            # => "M7A"
array_form.cars[1].driver           # => "Denis Hulme"
array_form.cars[1].engine.power     # => 415
array_form.cars[1].engine.volume    # => nil    - this value is nil because this car was created in updated_attributes

3. Serialize to Hash

Call to_hash() method in order to get hash representation of the form object

simple_form.to_hash     # => {
                        # =>    :name => "McLaren",
                        # =>    :year => 1966  
                        # => }

3.1. Nested Form Objects

nested_form.to_hash     # => {
                        # =>    :name => "McLaren",
                        # =>    :year => 1966,
                        # =>    :car  => {
                        # =>      :model => "340 F1",
                        # =>      :driver => "Ascari",
                        # =>      :engine => {
                        # =>        :power => 335,
                        # =>        :volume => 4.1
                        # =>      }   
                        # =>    }
                        # => }

3.2. Array of Form Objects

array_form.to_hash      # => {
                        # =>    :name => "McLaren",
                        # =>    :year => 1966,
                        # =>    :cars => [{
                        # =>      :model => "M2B",
                        # =>      :driver => "Bruce McLaren",
                        # =>      :engine => {
                        # =>        :power => 300,
                        # =>        :volume => 3.0
                        # =>      }
                        # =>    }, {
                        # =>      :model => "M7A",
                        # =>      :driver => "Denis Hulme",
                        # =>      :engine => {
                        # =>        :power => 415,
                        # =>        : volume => nil
                        # =>      }
                        # =>    }] 
                        # => }

4. Map Form Object to Models

Include Mappable mix-in and map form object attributes to one or few models by using :model and :model_attribute parameters. By default each form object attribute is mapped to the model attribute with the same name of the :default model.

Use dot notation to map model attribute to nested model. Use colon to specify "hash" attribute.

class SingleForm < FormObj::Form
  include Mappable

  attribute :name, model_attribute: :team_name
  attribute :year
  attribute :engine_power, model_attribute: 'car.:engine.power'
end

Suppose single_form = SingleForm.new and model to be an instance of a model.

Form Object attribute Model attribute
single_form.name model.team_name
single_form.year model.year
single_form.engine_power model.car[:engine].power

4.1. Multiple Models Example

class MultiForm < FormObj::Form
  include Mappable

  attribute :name, model_attribute: :team_name
  attribute :year
  attribute :engine_power, model: :car, model_attribute: ':engine.power'
end

Suppose multi_form = MultiForm.new and default, car to be instances of two models.

Form Object attribute Model attribute
multi_form.name default.team_name
multi_form.year default.year
multi_form.engine_power car[:engine].power

4.2. Skip Attribute Mapping

Use model_attribute: false in order to avoid attribute mapping to the model.

class SimpleForm < FormObj::Form
  include Mappable

   attribute :name, model_attribute: :team_name
  attribute :year
  attribute :engine_power, model_attribute: false
end

Suppose form = SimpleForm.new and model to be an instance of a model.

Form Object attribute Model attribute
form.name model.team_name
form.year model.year
form.engine_power -
4.2.1. Map Nested Form Object Attribute to Parent Level Model Attribute

Use model_attribute: false for nested form object in order to map its attributes to the parent level of the model.

class NestedForm < FormObj::Form
  include Mappable

  attribute :name, model_attribute: :team_name
  attribute :year
  attribute :car, model_attribute: false do   # nesting only in form object but not in a model
    attribute :model
    attribute :driver
    attribute :engine do
      attribute :power
      attribute :volume
    end
  end
end

Suppose form = NestedForm.new and model to be an instance of a model.

Form Object attribute Model attribute
form.name model.team_name
form.year model.year
form.car.model model.model
form.car.driver model.driver
form.car.engine.power model.engine.power
form.car.engine.volume model.engine.volume

4.3. Map Nested Form Object to A Hash Model

Use hash: true in order to map a nested form object to a hash as a model.

class NestedForm < FormObj::Form
  include Mappable

  attribute :name, model_attribute: :team_name
  attribute :year
  attribute :car, hash: true do   # nesting only in form object but not in a model
    attribute :model
    attribute :driver
    attribute :engine do
      attribute :power
      attribute :volume
    end
  end
end

Suppose form = NestedForm.new and model to be an instance of a model.

Form Object attribute Model attribute
form.name model.team_name
form.year model.year
form.car.model model.car[:model]
form.car.driver model.car[:driver]
form.car.engine.power model.car[:engine].power
form.car.engine.volume model.car[:engine].volume

5. Load Form Object from Models

Use load_from_models(models) to load form object attributes from mapped models. Method returns self so one can chain calls.

class MultiForm < FormObj::Form
  include Mappable

  attribute :name, model_attribute: :team_name
  attribute :year
  attribute :engine_power, model: :car, model_attribute: ':engine.power'
end

default_model = Struct.new(:team_name, :year).new('Ferrari', 1950)
car_model = { engine: Struct.new(:power).new(335) }

multi_form = MultiForm.new.load_from_models(default: default_model, car: car_model)
multi_form.to_hash    # => {
                      # =>    :name => "Ferrari"
                      # =>    :year => 1950
                      # =>    :engine_power => 335
                      # => }

Use load_from_models(default: model) or load_from_model(model) to load from single model.

6. Save Form Object to Models

Use save_to_models(models) to save form object attributes to mapped models. Method returns self so one can chain calls.

class MultiForm < FormObj::Form
  include Mappable

  attribute :name, model_attribute: :team_name
  attribute :year
  attribute :engine_power, model: :car, model_attribute: ':engine.power'
end

default_model = Struct.new(:team_name, :year).new('Ferrari', 1950)
car_model = { engine: Struct.new(:power).new(335) }

multi_form = MultiForm.new
multi_form.update_attributes(name: 'McLaren', year: 1966, engine_power: 415)
multi_form.save_to_models(default: default_model, car: car_model)

default_model.name          # => "McLaren"
default_model.year          # => 1966
car_model[:engine].power    # => 415 

Use save_to_models(default: model) or save_to_model(model) to save to single model.

Neither save_to_models nor save_to_model calls save method on the model(s). Also they don't call valid? method on the model(s). Instead they just assign form object attributes values to mapped model attributes using <attribute_name>= accessors on the model(s).

It is completely up to developer to do any additional validations on the model(s) and save it(them).

6.1. Array of Form Objects and Models

Saving array of form objects to corresponding array of models requires the class of the model to be known by the form object because it could create new instances of the model array elements. Use :model_class parameter to specify it. Form object will try to guess the name of the class from the name of the attribute if this parameter is absent.

class ArrayForm < FormObj::Form
  include Mappable

  attribute :name
  attribute :year
  attribute :cars, array: true, model_class: Car do
    attribute :model, primary_key: true     # <- primary key is specified on attribute level
    attribute :driver
  end
end

If corresponding :model_attribute parameter uses dot notations to reference nested models the value of :model_class parameter should be an array of corresponding model classes.

class ArrayForm < FormObj::Form
  include Mappable

  attribute :name
  attribute :year
  attribute :cars, array: true, model_attribute: 'equipment.cars', model_class: [Equipment, Car] do
    attribute :model, primary_key: true     # <- primary key is specified on attribute level
    attribute :driver
  end
end

7. Serialize Form Object to Model Hash

Use to_model_hash(model = :default) to get hash representation of the model that mapped to the form object.

class MultiForm < FormObj::Form
  include Mappable

  attribute :name, model_attribute: :team_name
  attribute :year
  attribute :engine_power, model: :car, model_attribute: ':engine.power'
end

multi_form = MultiForm.new
multi_form.update_attributes(name: 'McLaren', year: 1966, engine_power: 415)

multi_form.to_model_hash              # => { :team_name => "McLaren", :year => 1966 }
multi_form.to_model_hash(:default)    # => { :team_name => "McLaren", :year => 1966 }
multi_form.to_model_hash(:car)        # => { :engine => { :power => 415 } }

Use to_models_hash() to get hash representation of all models that mapped to the form object.

multi_form.to_models_hash             # => {
                                      # =>   default: { :team_name => "McLaren", :year => 1966 }
                                      # =>   car: { :engine => { :power => 415 } }
                                      # => } 

If array of form objects mapped to the parent model (model_attribute: false) it is serialized to :self key.

class ArrayForm < FormObj::Form
  include Mappable

  attribute :name
  attribute :year
  attribute :cars, array: true, model_attribute: false do
    attribute :model, primary_key: true
    attribute :driver
  end
end

array_form = ArrayForm.new
array_form.update_attributes(
    name: 'McLaren', 
    year: 1966, 
    cars: [{
      model: 'M2B', 
      driver: 'Bruce McLaren'
    }, {
      model: 'M7A',
      driver: 'Denis Hulme'
    }]
)

array_form.to_model_hash    # => { 
                            # =>    :team_name => "McLaren", 
                            # =>    :year => 1966,
                            # =>    :self => {
                            # =>      :model => "M2B",
                            # =>      :driver => "Bruce McLaren"
                            # =>    }, {    
                            # =>      :model => "M7A",
                            # =>      :driver => "Denis Hulme"
                            # =>    }  
                            # => }

8. Validation and Coercion

Form Object is just a Ruby class. By default it includes (could be changed in future releases):

  extend ::ActiveModel::Naming
  extend ::ActiveModel::Translation

  include ::ActiveModel::Conversion
  include ::ActiveModel::Validations

So add ActiveModel validations directly to Form Object class definition.

class MultiForm < FormObj::Form
  include Mappable

  attribute :name, model_attribute: :team_name
  attribute :year
  attribute :engine_power, model: :car, model_attribute: ':engine.power'

  validates :name, :year, presence: true
end

There is no coercion during assigning/updating form object attributes. Coercion can be done manually by redefining assigning methods <attribute_name>= or it will happen in the model when the form object will be saved to it. This is the standard way how coercion happens in Rails for example.

9. Copy Model Validation Errors into Form Object

Even though validation could and should happen in the form object it is possible to have (additional) validation(s) in the model(s). In this case it is handy to copy model validation errors to form object in order to be able to present them to the user in a standard way.

Use copy_errors_from_models(models) or copy_errors_from_model(model) in order to do it. Methods return self so one can chain calls.

multi_form.copy_errors_from_models(default: default_model, car: car_model)

In case of single model:

single_form.copy_errors_from_model(model)

10. Rails Example

# db/migrate/yyyymmddhhmiss_create_team.rb
class CreateTeam < ActiveRecord::Migration
  def change
    create_table :teams do |t|
      t.string :team_name
      t.integer :year
    end
  end
end
# app/models/team.rb
class Team < ApplicationRecord
  has_many :cars, autosave: true

  validates :year, numericality: { greater_than_or_equal_to: 1950 }
end
# db/migrate/yyyymmddhhmiss_create_car.rb
class CreateCar < ActiveRecord::Migration
  def change
    create_table :cars do |t|
      t.references :team    
      t.string :model
      t.text :engine
    end
  end
end
# app/models/car.rb
class Car < ApplicationRecord
  belongs_to :team

  serialize :engine, Hash
end
# app/form_objects/team_form.rb
class TeamForm < FormObj::Form
  include Mappable

  attribute :id
  attribute :name, model_attribute: :team_name
  attribute :year
  attribute :cars, array: true do
    attribute :id
    attribute :model
    attribute :engine_power, model_attribute: 'engine.:power'

    validates :model, presence: true
  end

  validates :name, :year, presence: true
end
# app/controllers/teams_controller.rb
class TeamsController < ApplicationController
  def show
    @team = TeamForm.new.load_from_model(Team.find(params[:id])) 
  end

  def new
    @team = TeamForm.new
  end

  def edit
    @team = TeamForm.new.load_from_model(Team.find(params[:id]))
  end

  def create
    @team = TeamForm.new.update_attributes(params[:team])

    if @team.valid?
      @team.save_to_model(model = Team.new) 
      if model.save
        return redirect_to team_path(model), notice: 'Team has been created'
      else
        @team.copy_errors_from_model(model) 
      end
    end

    render :new
  end

  def update
    @team = TeamForm.new.load_from_model(model = Team.find(params[:id]))
    @team.update_attributes(params[:team])

    if @team.valid?
      @team.save_to_model(model)
      if model.save
        return redirect_to team_path(model), notice: 'Team has been updated'
      else
        @team.copy_errors_from_model(model)
      end
    end

    render :edit
  end
end
# app/views/teams/show.erb.erb
<p>Name: <%= @team.name %></p> 
<p>Year: <%= @team.year %></p>
<p>Cars:</p>
<ul>
  <% @team.cars.each do |car| %>
    <li><%= car.model %> (<%= car.engine[:power] %> hp)</li>    
  <% end %>
</ul>
# app/views/teams/new.erb.erb
<%= nested_form_for @team do |f| %>
  <%= f.text_field :name %>
  <%= f.text_field :year %>

  <%= f.link_to_add 'Add a Car', :cars %>
<% end %>
# app/views/teams/edit.erb.erb
<%= nested_form_for @team do |f| %>
  <%= f.text_field :name %>
  <%= f.text_field :year %>

  <%= f.fields_for :cars do |cf| %>
    <%= cf.text_field :model %>
    <%= cf.link_to_remove 'Remove the Car' %> 
  <% end %>
  <%= f.link_to_add 'Add a Car', :cars %>
<% end %>

11. Reference Guide: attribute parameters

Parameter Condition Default value Defined in Description
array block* or :class** false TreeStruct This attribute is an array of form objects. The structure of array element form object is described either in the block or in the separate class referenced by :class parameter
class - - TreeStruct This attribute is either nested form object or array of form objects. The value of this parameter is the class of this form object or the name of the class.
hash block* or :class** false FormObj::Mappable This attribute is either nested form object or array of form objects. This form object is mapped to a model of the class Hash so all its attributes should be accessed by [:<attribute_name>] instead of .<attribute_name>
model - :default FormObj::Mappable The name of the model to which this attribute is mapped
model_attribute - <attribute_name> FormObj::Mappable The name of the model attribute to which this form object attribute is mapped. Dot notation is used in order to map to nested model, ex. "car.engine.power". Colon is used in front of the name if the model is hash, ex. "car.:engine.power" - means call to #car returns Hash so the model attribute should be accessed like car[:engine].power. false value means that attribute is not mapped. If attribute describes nested form object and has model_attribute: false the attributes of nested form will be called on the parent (upper level) model. If attribute describes array of form objects and has model_attribute: false the methods to access array elements (:[] etc.) will be called on the parent (upper level) model.
model_class block* or :class** or dot notation for :model_attribute*** <attribute_name>.classify FormObj::Mappable The class (or the name of the class) of the mapped model.
primary_key no block* and no :class** false FormObj::Form This attribute is the primary key of the form object. The mapped model attribute is considered to be a primary key for the corresponding model.
primary_key block* or :class** - FormObj::Form This attribute is either nested form object or array of form objects. The value of this parameter is the name of the primary key attribute of this form object.

* block - means that there is block definition for the attribute

** :class - means that this attribute has :class parameter specified

*** dot notation for :model_attribute - means that this attribute is mapped to nested model attribute (using dot notation)

Development

After checking out the repo, run bin/setup to install dependencies. Then, run rake spec to run the tests. You can also run bin/console for an interactive prompt that will allow you to experiment.

To install this gem onto your local machine, run bundle exec rake install. To release a new version, update the version number in version.rb, and then run bundle exec rake release, which will create a git tag for the version, push git commits and tags, and push the .gem file to rubygems.org.

Contributing

Bug reports and pull requests are welcome on GitHub at https://github.com/akoltun/form_obj.

License

The gem is available as open source under the terms of the MIT License.