Sonntag, 31. Mai 2015

Clean up nested forms with form objects in Rails!

Nested forms consist of form fields representing at least 2 objects. For example one form with user and address attributes. In Ruby On Rails nested forms very often are built with ActiveRecord::NestedAttributes::ClassMethods#accepts_nested_attributes_for and ActionView::Helpers::FormHelper#fields_for. Meanwhile it is proven going that path is a bad idea for several reasons. I fiddles with awful request parameters, burdens the model with false responsibilities and is hard to test. But there is an alternative, which decreases complexity: form objects.
Whenever:
  1. Nested forms
  2. Virtual model attributes
  3. Multiple varying forms for one resource
are required, form objects have to be considered, because they:
  1. Extract responsibility from the model objects
  2. Decouple models from forms
  3. Simplify forms
  4. Flatten parameter hashes and therefore simplify parameter check
  5. Simplify form handling when there are multiple different forms attached to one resource
The more a form is getting complex the more an appropriate form object is the solution.
For example a user form with address data has to be saved at once. First the Address model (address.rb):
class Address < ActiveRecord::Base
  validates :street, :number, presence: true
end
Starting from the original model object user.rb:
class User < ActiveRecord::Base
  belongs_to :address
  validates :name, presence: true
  accepts_nested_attributes_for :address
end
a new form object class (user_address.rb) has to be created to achieve the goal:
class UserAddress
  include ActiveModel::Model
  validates :name, :street, :number, presence: true
  delegate :name, :save, to: :user
  delegate :street, :number, to: :address

  def attributes= new_attributes
    user.attributes = new_attributes.slice :name
    address.attributes = new_attributes.slice :street, :number
  end 

  def user
    @user ||= User.new
  end 

  def address
    @address ||= user.build_address
  end 
end
The UserAddress is a lightweight plain old Ruby object including the ActiveModel::Model, which just means including validation and conversion stuff. To marry User and Address there is a reader acessor for each object and also delegators to their required attribute acessor methods. At least there is an writing accessor for all attributes which just fills both objects.
At first sight the form object seems to cost more effort, than just using accepts_nested_attributes_for, but it offers more flexibility especially when the objects get more complex. Furthermore form logic baggage is extracted from the User model object, which is NOT responsible for it. And the good thing is, the controller and the view stuff is way cleaner through the form object.
The original working but awful nested form (users/_form.html.haml)
= form_for @user do |user_form|
  .text
    = user_form.label :name
    = user_form.text_field :name
  = user_form.fields_for :address do |address_fields|
    .text
      = address_fields.label :street
      = address_fields.text_field :street
    .text
      = address_fields.label :number
      = address_fields.text_field :number

  = user_form.submit 'Save'
could be way cleaner, if it looked like:
= form_for @user_address do |f| 
  .text
    = f.label :name
    = f.text_field :name
  .text
    = f.label :street
    = f.text_field :street
  .text
    = f.label :number
    = f.text_field :number

  = f.submit 'Save'
Please note the @user_address representing the form object and the straightforward form without any nesting.
Also compare the parameter hash from the nested:
"user" => { 
  "name" => "Chris", 
  "address_attributes" => { 
    "street" => "Main street", 
    "number" => "1" 
  } 
}
to the flattened:
"user_address" => { 
  "name" => "Chris", 
  "street" => "Main street", 
  "number" => "1" 
}
Finally even the original controller (controllers/users_controller.rb):
class UsersController < ApplicationController  
  def new 
    @user = User.new
    @user.build_address
  end 

  def create
    @user = User.new 
    @user.attributes = user_params
    @user.save
  end 
  private
  def user_params
    params.require(:user)
      .permit(:name, address_attributes: [:street, :number])
  end 
end
can be moved to the new resource (controllers/user_addresses_controller.rb):
class UserAddressesController < ApplicationController  
  def new 
    @user_address = UserAddress.new
  end 

  def create
    @user_address = UserAddress.new 
    @user_address.attributes = user_address_params
    @user_address.save
  end 
  private
  def user_address_params
    params.require(:user_address)
     .permit(:name, :street, :number)
  end 
end
having an easier parameter check.
Even that small example illustrates how nested forms can be simplified with lots of nice side effects.
Further articles of interest:

Supported by Ruby 2.2.1 and Ruby on Rails 4.2.1

Keine Kommentare:

Kommentar veröffentlichen