Form Object from Railscasts

Models in a Rails application can easily become very complicated as more logic gets added to them. Fortunately there are several ways to refactor them that can help to clean up them up. Most of the behaviour in the User model of our example application is to do with forms. It has some custom virtual accessors, a number of validations, some callbacks and an association that accepts nested attributes for another model. It also has some virtual attributes and custom validations.

class User < ActiveRecord::Base
  has_secure_password
  attr_accessor :changing_password, :original_password, :new_password

  validates_presence_of :username
  validates_uniqueness_of :username
  validates_format_of :email, with: /\A([^@\s]+)@((?:[-a-z0-9]+\.)+[a-z]{2,})\Z/
  validates_length_of :password, minimum: 6, on: :create
  validate :verify_original_password, if: :changing_password
  validates_presence_of :original_password, :new_password, if: :changing_password
  validates_confirmation_of :new_password, if: :changing_password
  validates_length_of :new_password, minimum: 6, if: :changing_password

  before_create :generate_token
  before_update :change_password, if: :changing_password

  has_one :profile
  accepts_nested_attributes_for :profile

  def subscribed
    subscribed_at
  end

  def subscribed=(checkbox)
    subscribed_at = Time.zone.now if checkbox == "1"
  end

  # Other methods omitted.
end

By extracting these out into form objects we can clean up our model considerably.

# app/forms/password_form.rb

class PasswordForm
  include ActiveModel::Model

  def persisted?
    false
  end

  attr_accessor :original_password, :new_password

  validate :verify_original_password
  validates_presence_of :original_password, :new_password
  validates_confirmation_of :new_password
  validates_length_of :new_password, minimum: 6

  def initialize(user)
    @user = user
  end
  
  def submit(params)
    self.original_password = params[:original_password]
    self.new_password = params[:new_password]
    self.new_password_confirmation = params[:new_password_confirmation]
    if valid?
      @user.password = new_password
      @user.save!
      true
    else
      false
    end
  end

  def verify_original_password
    unless @user.authenticate(original_password)
      errors.add :original_password, "is not correct"
    end
  end
end

# app/forms/signup_form.rb

class SignupForm
  include ActiveModel::Model

  def persisted?
    false
  end
  
  def self.model_name
    ActiveModel::Name.new(self, nil, "User")
  end

  validates_presence_of :username
  validate :verify_unique_username
  validates_format_of :email, with: /\A([^@\s]+)@((?:[-a-z0-9]+\.)+[a-z]{2,})\Z/
  validates_length_of :password, minimum: 6
  
  delegate :username, :email, :password, :password_confirmation, to: :user
  delegate :twitter_name, :github_name, :bio, to: :profile
  
  def user
    @user ||= User.new
  end
  
  def profile
    @profile ||= user.build_profile
  end
  
  def submit(params)
    user.attributes = params.slice(:username, :email, :password, :password_confirmation)
    profile.attributes = params.slice(:twitter_name, :github_name, :bio)
    self.subscribed = params[:subscribed]
    if valid?
      generate_token
      user.save!
      profile.save!
      true
    else
      false
    end
  end

  def subscribed
    user.subscribed_at
  end

  def subscribed=(checkbox)
    user.subscribed_at = Time.zone.now if checkbox == "1"
  end

  def generate_token
    begin
      user.token = SecureRandom.hex
    end while User.exists?(token: user.token)
  end

  def verify_unique_username
    if User.exists? username: username
      errors.add :username, "has already been taken"
    end
  end
end

Now methods :new and :create in our UsersController will look much cleaner.

def new
  @signup_form = SignupForm.new
end

def create
  @signup_form = SignupForm.new
  if @signup_form.submit(params[:user])
    session[:user_id] = @signup_form.user.id
    redirect_to @signup_form.user, notice: "Thank you for signing up!"
  else
    render "new"
  end
end

Here is an example of how we are gonna use it.

# users/new.html.erb

<%= form_for @signup_form do |f| %>

The same works for PasswordsController.

def new
  @password_form = PasswordForm.new(current_user)
end

def create
  @password_form = PasswordForm.new(current_user)
  if @password_form.submit(params[:password_form])
    redirect_to current_user, notice: "Successfully changed password."
  else
    render "new"
  end
end

Usage:

# passwords/new.html.erb

<%= form_for @password_form, url: passwords_path, method: :post do |f| %>
Alexander M Over 7 years ago