Practical examples to master web forms in Rails
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)