Railsamples

Practical examples to master web forms in Rails

Rails Nested Address Form Object With Aggregated ActiveModel Errors

This example is a comment on the reddit post: https://www.reddit.com/r/rails/comments/1urmmw1/rails_nested_address_form_object_with_aggregated/ and the blog post: https://codesnips.io/1074-rails-nested-address-form-object-with-aggregated-activemodel-errors In this example the use of fields_for prevents from having to merge errors to nested objects. I changed a couple of things from the original code while trying to keep the intent. For example, `same_as_shipping` is now part of a BillingAddressForm instead of being on the main checkout form. Logic is pretty much the same but note the use of shipping_address_attributes= and billing_address_attributes= accessors instead of the building_addresses from the original post.

Disclaimer: Always double check the code you're copying is safe. Check out our About section to learn more about UniRails gem.

ENV["SECRET_KEY_BASE"] = "1212312313"
ENV["DATABASE_URL"] = "sqlite3:///#{__dir__}/database.sqlite"

require "bundler/inline"

gemfile do
  source "https://www.rubygems.org"
  gem "uni_rails", "~> 0.5.0"
  gem "sqlite3", "~> 1.7"
  gem "debug"
end

require "uni_rails"
require "sqlite3"
require "debug"

#  ==== ROUTES ====

UniRails::App.routes.append do
  root "orders#index"
  resources :orders, only: [:index, :show]
  resource :checkout, only: [:new, :create]
end

#  ==== DB SCHEMA ====

ActiveRecord::Base.establish_connection
ActiveRecord::Schema.define do
  create_table :orders, force: :cascade do |t|
    t.string :email, null: false
  end

  create_table :addresses, force: :cascade do |t|
    t.string :line1,       null: false
    t.string :line2
    t.string :city,        null: false
    t.string :region,      null: false
    t.string :postal_code, null: false
    t.string :country,     null: false, default: "US"
    t.string :kind,        null: false

    t.references :order
  end
end

#  ==== MODELS ====

# Active Records

class Order < ActiveRecord::Base
  has_one :shipping_address, -> { where(kind: 'shipping') }, class_name: 'Address'
  has_one :billing_address,  -> { where(kind: 'billing') },  class_name: 'Address'
end

class Address < ActiveRecord::Base; end

# Checkout Forms

class AddressForm
  include ActiveModel::Model
  include ActiveModel::Attributes

  attribute :line1,       :string
  attribute :line2,       :string
  attribute :city,        :string
  attribute :region,      :string
  attribute :postal_code, :string
  attribute :country,     :string, default: "US"
end

class ShippingAddressForm < AddressForm
  validates_presence_of :line1, :city, :region, :country, :postal_code
end

class BillingAddressForm < AddressForm
  attribute :same_as_shipping, :boolean, default: false

  validates_presence_of :line1, :city, :region, :country, :postal_code,
                        unless: :same_as_shipping

  def attributes
    super.except('same_as_shipping')
  end
end

class CheckoutForm
  include ActiveModel::Model
  include ActiveModel::Attributes

  attribute :email, :string
  attribute :shipping_address
  attribute :billing_address

  validates :email, presence: true
  validate :validate_addresses

  def save
    valid? && Order.create(
      email: email,
      shipping_address: Address.new(shipping_address.attributes.merge('kind' => 'shipping')),
      billing_address: Address.new(effective_billing.attributes.merge('kind' => 'billing'))
    )
  end

  def shipping_address_attributes=(attributes)
    self.shipping_address = ShippingAddressForm.new(attributes)
  end

  def billing_address_attributes=(attributes)
    self.billing_address = BillingAddressForm.new(attributes)
  end

  private

  def effective_billing
    billing_address.same_as_shipping ? shipping_address : billing_address
  end

  def validate_addresses
    a = shipping_address.valid?
    b = billing_address.valid?
    unless a && b
      errors.add(:base, 'addresses invalid')
    end
  end
end

#  ==== CONTROLLERS ====

class OrdersController < ActionController::Base
  layout 'application'

  def index
    @orders = Order.all
  end

  def show
    @order = Order.find(params[:id])
  end
end

class CheckoutsController < ActionController::Base
  layout 'application'

  def new
    # Puts some defaults to easily edit the form

    @checkout = CheckoutForm.new(
      email: 'email',
      shipping_address: ShippingAddressForm.new(
        line1: 'line1',
        line2: 'line2',
        city: 'city',
        region: 'region',
        postal_code: 'postal_code'
      ),
      billing_address: BillingAddressForm.new(
        same_as_shipping: true
      )
    )
  end

  def create
    @checkout = CheckoutForm.new(checkout_params)
    if @checkout.save
      redirect_to orders_path, notice: 'order created succesffully'
    else
      render :new
    end
  end

  private

  def checkout_params
    params.require(:checkout_form).permit(
      :email,
      shipping_address_attributes: %i[line1 line2 city region postal_code country],
      billing_address_attributes:  %i[line1 line2 city region postal_code country same_as_shipping]
    )
  end
end

#  ==== CSS ====

UniRails.css <<~CSS
html { background-color:#EEE; } body { width:500px; margin:auto; background-color:white; padding:1rem; } form { .errors { color:red; } label { display: block; } input[type="submit"] { display: block; margin-top:1rem; } .field_with_errors { color:red; display:inline; } } article { margin-top:1rem; border: 1px solid black; padding:0.5rem; p { margin:0 } }
CSS # ==== VIEWS ==== UniRails.register_view "orders/index.html.erb", <<~HTML
<h1>Orders</h1> <p><%= link_to 'Checkout', new_checkout_path %></p> <% if @orders.present? %> <ul> <% @orders.each do |order| %> <li><%= link_to order.id.to_s + ' - ' + order.email , order %></li> <% end %> </ul> <% else %> <p>There are no orders</p> <% end %>
HTML UniRails.register_view "orders/show.html.erb", <<~HTML
<h1>Order: <%= @order.id %></h1> <p><%= link_to 'Back to orders', :back %></p> <pre><code><%= raw JSON.pretty_generate @order.as_json(include:[:shipping_address, :billing_address]) %></code></pre>
HTML UniRails.register_view "checkouts/new.html.erb", <<~HTML
<%= form_with model: @checkout, url: checkout_path do |f| %> <p class="errors"><%= @checkout.errors.full_messages.to_sentence %></p> <%= f.label :email %> <%= f.text_field :email %> <fieldset> <legend>Shipping Address</legend> <%= f.fields_for :shipping_address do |ff| %> <p class="errors"><%= ff.object.errors.full_messages.to_sentence %></p> <%= ff.label :line1 %> <%= ff.text_field :line1 %> <%= ff.label :line2 %> <%= ff.text_field :line2 %> <%= ff.label :city %> <%= ff.text_field :city %> <%= ff.label :region %> <%= ff.text_field :region %> <%= ff.label :postal_code %> <%= ff.text_field :postal_code %> <%= ff.label :country %> <%= ff.text_field :country %> <% end %> </fieldset> <fieldset> <legend>Billing Address</legend> <%= f.fields_for :billing_address do |ff| %> <p class="errors"><%= ff.object.errors.full_messages.to_sentence %></p> <%= ff.label :same_as_shipping %> <%= ff.check_box :same_as_shipping %> <%= ff.label :line1 %> <%= ff.text_field :line1 %> <%= ff.label :line2 %> <%= ff.text_field :line2 %> <%= ff.label :city %> <%= ff.text_field :city %> <%= ff.label :region %> <%= ff.text_field :region %> <%= ff.label :postal_code %> <%= ff.text_field :postal_code %> <%= ff.label :country %> <%= ff.text_field :country %> <% end %> </fieldset> <%= f.submit %> <% end %>
HTML UniRails.run(Port: 3000)