Code Climate

RFO - Rails Form Object

Inspired by 7 Patterns to Refactor Fat ActiveRecord Models and SRP

What it does?

  • simplify forms with multiple models - we do not have to use nested attributes
  • remove validations from models - they do too much already. What is more each form can have different validations.
  • can substitude strong parameters - we define exacly what and how we want to assign values in models

Installation

Add this line to your application's Gemfile:

gem 'rfo', github: 'petergebala/rfo'

And then execute:

$ bundle

Or install it yourself as:

$ gem install rfo # Not yet!

Usage

It works great with Draper and Simple Form

Define simple model with relations eventually callbacks:

class Organisation < ActiveRecord::Base
  WEBSITE_REGEXP = /[a-z0-9]+([\-\.]{1}[a-z0-9]+)*\.[a-z]{2,5}(:[0-9]{1,5})?(\/.*)?/ix
  APPLY_FOR = [:under_2000, :over_2000]

  belongs_to :grant, touch: true
  has_one :address, as: :entity, dependent: :destroy
  has_one :contact, as: :entity, dependent: :destroy
  has_one :user, through: :grant

  after_create :create_address
  after_create :create_contact

  delegate :postcode, to: :address
end

Define form:

  • fields which you will use in form
  • validations for this form
  • default values
  • how and where it should assign values
# app/forms/organisation_form.rb
class OrganisationForm < RFO::Base
  # About your organisation
  attribute :amount_apply_for,  String
  attribute :organisation_name, String
  attribute :charity_number,    String

  # Your address details
  attribute :first_name,        String
  attribute :last_name,         String
  attribute :position,          String
  attribute :address_line_1,    String
  attribute :address_line_2,    String
  attribute :address_line_3,    String
  attribute :town,              String
  attribute :county,            String
  attribute :postcode,          String

  # Your contact details
  attribute :phone_number,      String
  attribute :mobile_number,     String

  # Your organisation
  attribute :website,           String
  attribute :description,       String

  # Validations
  validates :description,       presence: true, length: { maximum: 2000 }
  validates :amount_apply_for,  presence: true, inclusion: { in: Organisation::APPLY_FOR.map(&:to_s) }
  validates :organisation_name, presence: true, length: { maximum: 255 }
  validates :first_name,        presence: true, length: { maximum: 255 }
  validates :last_name,         presence: true, length: { maximum: 255 }
  validates :address_line_1,    presence: true, length: { maximum: 255 }
  validates :town,              presence: true, length: { maximum: 255 }
  validates :postcode,          presence: true, length: { maximum: 10 },  format: { with: Address::POSTCODE_REGEXP }
  validates :phone_number,      presence: true, length: { maximum: 20 },  format: { with: Contact::PHONE_NUMBER_REGEXP }
  validates :mobile_number,                     length: { maximum: 20 },  format: { with: Contact::PHONE_NUMBER_REGEXP }, allow_blank: true
  validates :website,                           length: { maximum: 255 }, format: { with: Organisation::WEBSITE_REGEXP }, allow_blank: true

  private
  def assign_defaults
    @grant.current_step = :organisation_details

    organisation_contact = @organisation.contact
    organisation_address = @organisation.address
    user_contact         = @organisation.user.contact
    user_address         = @organisation.user.address

    # Initialize with values from diffrent models
    self.organisation_name ||= @organisation.name
    self.first_name        ||= organisation_contact.first_name     || user_contact.first_name
    self.last_name         ||= organisation_contact.last_name      || user_contact.last_name
    self.position          ||= organisation_contact.position       || user_contact.position
    self.phone_number      ||= organisation_contact.phone_number   || user_contact.phone_number
    self.mobile_number     ||= organisation_contact.mobile_number  || user_contact.mobile_number
    self.address_line_1    ||= organisation_address.address_line_1 || user_address.address_line_1
    self.address_line_2    ||= organisation_address.address_line_2 || user_address.address_line_2
    self.address_line_3    ||= organisation_address.address_line_3 || user_address.address_line_3
    self.town              ||= organisation_address.town           || user_address.town
    self.county            ||= organisation_address.county         || user_address.county
    self.postcode          ||= organisation_address.postcode       || user_address.postcode
  end

  def persist!
    ActiveRecord::Base.transaction do |t|
      @organisation.name             = self.organisation_name
      @organisation.amount_apply_for = self.amount_apply_for
      @organisation.charity_number   = self.charity_number
      @organisation.website          = self.website
      @organisation.description      = self.description

      @contact               = @organisation.contact
      @contact.first_name    = self.first_name
      @contact.last_name     = self.last_name
      @contact.position      = self.position
      @contact.phone_number  = self.phone_number
      @contact.mobile_number = self.mobile_number

      @address                = @organisation.address
      @address.address_line_1 = self.address_line_1
      @address.address_line_2 = self.address_line_2
      @address.address_line_3 = self.address_line_3
      @address.town           = self.town
      @address.county         = self.county
      @address.postcode       = self.postcode

      @grant.current_step = @grant.next_step

      @organisation.save!
      @contact.save!
      @address.save!
      @grant.save!
    end
  end
end

Define skinny controller:

class OrganisationDetailsController < ApplicationController
  before_filter :set_grant
  respond_to :html

  before_filter :set_organisation, only: [:edit, :update]

  def edit ; end

  def update
    flash[:notice] = 'Organisation details saved!' if @organisation_form.update_attributes(organisations_params)
    respond_with @organisation_form, location: @grant.current_path
  end

  private
  def set_grant
    @grant = current_user.grants.find(params[:grant_id]).decorate
  end

  def set_organisation
    @organisation = @grant.organisation.decorate
    @organisation_form = OrganisationForm.new(organisation: @organisation,
                                              grant: @grant)
  end

  def organisations_params
    params.require(:organisation_form).permit(:amount_apply_for,
                                              :organisation_name,
                                              :charity_number,
                                              :first_name,
                                              :last_name,
                                              :position,
                                              :address_line_1,
                                              :address_line_2,
                                              :address_line_3,
                                              :town,
                                              :county,
                                              :postcode,
                                              :phone_number,
                                              :mobile_number,
                                              :website,
                                              :description)
  end
end

And clean view!

= simple_form_for @organisation_form, url: grant_organisation_details_path(@grant), method: :patch do |f|
  = f.error_notification
  h2 About Your Organisation
  fieldset
    = f.input :amount_apply_for,
      collection: @organisation.amount_apply_for_buttons_for_views,
      as: :radio_buttons,
      wrapper: :bootstrap_group_horizontal
    = f.input :organisation_name
    = f.input :charity_number
  h2 Your Address Details
  fieldset
    = f.input :first_name
    = f.input :last_name
    = f.input :position
    = f.input :address_line_1
    = f.input :address_line_2
    = f.input :address_line_3
    = f.input :town
    = f.input :county
    = f.input :postcode
  h2 Your Contact Details
  fieldset
    = f.input :phone_number
    = f.input :mobile_number
  h2 Your Organisation
  fieldset
    = f.input :website, as: :addon, input_html: { addon_text: 'http://' }
    = f.input :description, as: :text
  h2 Proceed
  fieldset
    = f.button :submit

Additional information

Becasue Form Object is just plain ruby class you can:

  • test it in simple way,
  • share common code (like validations) between form objects,
  • inherit between forms.

Contributing

  1. Fork it
  2. Create your feature branch (git checkout -b my-new-feature)
  3. Commit your changes (git commit -am 'Add some feature')
  4. Push to the branch (git push origin my-new-feature)
  5. Create new Pull Request

TODO:

  • turn off strong_parameters
  • write tests
  • correct documentation
  • update documentation with has_many relation and show how to remove nested_attributes